Share via


Writing Faster Managed Code: Know What Things Cost

 

Jan Gray
Équipe de performances Microsoft CLR

Juin 2003

S’applique à :
   Microsoft® .NET Framework

Résumé: Cet article présente un modèle de faible coût pour le temps d’exécution du code managé, basé sur les temps d’opération mesurés, afin que les développeurs puissent prendre des décisions de codage mieux informées et écrire du code plus rapidement. (30 pages imprimées)

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

Contenu

Introduction (et engagement)
Vers un modèle de coût pour le code managé
Coût des choses dans le code managé
Conclusion
Ressources

Introduction (et engagement)

Il existe une myriade de façons d’implémenter un calcul, et certaines sont bien meilleures que d’autres : plus simple, plus propre, plus facile à gérer. Certaines façons sont extrêmement rapides et d’autres sont étonnamment lentes.

Ne perpétrez pas de code lent et gros sur le monde. Tu méprises pas ce code ? Le code qui s’exécute dans les ajustements et les démarrages ? Code qui verrouille l’interface utilisateur pendant quelques secondes à la fois ? Code qui fixe le processeur ou qui batte le disque ?

Ne le faites pas. Au lieu de cela, tenez-vous debout et engagez-vous avec moi :

« Je promets de ne pas expédier de code lent. La vitesse est une fonctionnalité qui m’intéresse. Chaque jour, je serai attentif aux performances de mon code. Je mesure régulièrement et méthodiquement sa vitesse et sa taille. Je vais apprendre, créer ou acheter les outils dont j’ai besoin pour ce faire. C’est ma responsabilité.

(Vraiment.) Alors avez-vous promis ? C'est bien.

Comment écrire le code le plus rapide et le plus serré jour après jour ? Il s’agit de choisir consciemment la méthode frugale de préférence à la manière extravagante, gonflée, encore et encore, et une question de penser à travers les conséquences. Toute page de code donnée capture des dizaines de ces petites décisions.

Mais vous ne pouvez pas faire de choix judicieux parmi les alternatives si vous ne savez pas ce qui coûte : vous ne pouvez pas écrire de code efficace si vous ne savez pas ce que cela coûte.

C’était plus facile dans le bon vieux temps. Les bons programmeurs C le savaient. Chaque opérateur et opération en C, qu’il s’agit d’une affectation, d’un entier ou d’un calcul à virgule flottante, déréférencement ou appel de fonction, mappait plus ou moins un-à-un à une seule opération de machine primitive. Certes, parfois, plusieurs instructions de machine étaient nécessaires pour placer les opérandes appropriés dans les bons registres, et parfois une seule instruction pouvait capturer plusieurs opérations C (célèbre), *dest++ = *src++;mais vous pouviez généralement écrire (ou lire) une ligne de code C et savoir où le temps passait. Pour le code et les données, le compilateur C était WYWIWYG : « ce que vous écrivez est ce que vous obtenez ». (L’exception était et est, les appels de fonction. Si vous ne savez pas ce que coûte la fonction, vous ne savez pas exactement.)

Dans les années 1990, pour profiter des nombreux avantages de l’ingénierie logicielle et de la productivité de l’abstraction des données, de la programmation orientée objet et de la réutilisation du code, l’industrie des logiciels pc a fait une transition de C à C++.

C++ étant un sur-ensemble de C, il s’agit d’un « paiement à l’utilisation » (les nouvelles fonctionnalités ne coûtent rien si vous ne les utilisez pas), de sorte que l’expertise en programmation C, y compris le modèle de coût internalisé, est directement applicable. Si vous prenez du code C de travail et que vous le recompilez pour C++, la surcharge de temps et d’espace d’exécution ne devrait pas changer beaucoup.

D’autre part, C++ introduit de nombreuses nouvelles fonctionnalités de langage, notamment les constructeurs, les destructeurs, les nouveaux, les héritages uniques, multiples et virtuels, les casts, les fonctions membres, les fonctions virtuelles, les opérateurs surchargés, les pointeurs vers les membres, les tableaux d’objets, la gestion des exceptions et leurs compositions, ce qui entraîne des coûts cachés non négligeables. Par exemple, les fonctions virtuelles coûtent deux indirections supplémentaires par appel et ajoutent un champ de pointeur de table virtuelle masqué à chaque instance. Ou considérez que ce code à l’aspect anoueux :

{ complex a, b, c, d; … a = b + c * d; }

compile en environ treize appels de fonction membres implicites (éventuellement inclus).

Il y a neuf ans, nous avons exploré ce sujet dans mon article C++: Sous le capot. J’ai écrit :

« Il est important de comprendre comment votre langage de programmation est implémenté. Une telle connaissance dissipe la peur et l’émerveillement de « Que fait le compilateur ici-bas? » ; donne confiance à l’utilisation des nouvelles fonctionnalités ; et fournit des informations lors du débogage et de l’apprentissage d’autres fonctionnalités linguistiques. Cela donne également une idée des coûts relatifs des différents choix de codage nécessaires pour écrire le code le plus efficace au jour le jour.

Nous allons maintenant examiner de la même façon le code managé. Cet article explore les faibles coûts de temps et d’espace de l’exécution managée, afin que nous puissions faire des compromis plus intelligents dans notre codage quotidien.

Et tenir nos promesses.

Pourquoi le code managé ?

Pour la grande majorité des développeurs de code natif, le code managé est une meilleure plateforme plus productive pour exécuter leurs logiciels. Il supprime des catégories entières de bogues, telles que les altérations du tas et les erreurs hors limites d’index de tableau qui conduisent si souvent à des sessions de débogage en fin de nuit frustrantes. Il prend en charge les exigences modernes telles que le code mobile sécurisé (via la sécurité de l’accès au code) et les services Web XML, et par rapport au vieillissant Win32/COM/ATL/MFC/VB, le .NET Framework est une conception d’ardoise rafraîchissante propre, où vous pouvez en faire plus avec moins d’efforts.

Pour votre communauté d’utilisateurs, le code managé permet des applications plus riches et plus robustes, et une meilleure vie grâce à de meilleurs logiciels.

Quel est le secret de l’écriture de code managé plus rapide ?

Le simple fait d’en faire plus avec moins d’efforts n’est pas une licence d’abdiquer votre responsabilité de coder judicieusement. Tout d’abord, vous devez l’admettre à vous-même: « Je suis un débutant. » Tu es un débutant. Je suis un débutant aussi. Nous sommes tous des babes dans le domaine du code managé. Nous apprenons tous les ficelles, y compris ce qui coûte.

Quand il s’agit du .NET Framework riche et pratique, c’est comme si nous ions des enfants dans le magasin de bonbons. « Wow, je n’ai pas besoin de faire tout ce qui est fastidieux strncpy , je peux juste '+' chaînes ensemble! Wow, je peux charger un mégaoctet de XML dans quelques lignes de code ! Whoo-hoo!

C’est si facile. Si facile, en effet. Il est donc facile de graver des mégaoctets de RAM d’analyse d’infosets XML pour en extraire quelques éléments. En C ou C++, il était tellement douloureux que vous y réfléchiriez à deux fois. Peut-être que vous créeriez une machine d’état sur une API de type SAX. Avec le .NET Framework, il vous suffit de charger l’ensemble d’informations en un seul gulp. Peut-être même que vous le faites encore et encore. Alors peut-être que votre application ne semble plus si rapide. Peut-être qu’il a un ensemble de travail de plusieurs mégaoctets. Vous auriez peut-être dû réfléchir à deux fois à ce que ces méthodes faciles coûtent...

Malheureusement, à mon avis, la documentation actuelle du .NET Framework ne détaille pas correctement les implications en matière de performances des types et méthodes d’infrastructure. Elle ne spécifie même pas quelles méthodes peuvent créer de nouveaux objets. La modélisation des performances n’est pas un sujet facile à aborder ou à documenter; mais quand même, le « ne pas savoir » rend beaucoup plus difficile pour nous de prendre des décisions éclairées.

Étant donné que nous sommes tous des débutants ici, que nous ne savons pas ce qui coûte et que les coûts ne sont pas clairement documentés, que devons-nous faire?

Mesurez-le. Le secret est de le mesurer et d’être vigilant. Nous devrons tous prendre l’habitude de mesurer le coût des choses. Si nous allons à la peine de mesurer ce que les choses coûtent, alors nous ne serons pas ceux qui appelleront par inadvertance une nouvelle méthode whizzy qui coûte dix fois ce que nous avons supposé qu’elle coûtait.

(En passant, pour obtenir des informations plus approfondies sur les sous-jacents de performances de la bibliothèque de classes de base ou du CLR lui-même, envisagez de jeter un coup d’œil à l’interface CLI source partagée, également appelé Rotor. Le code rotor partage une lignée avec le .NET Framework et le CLR. Ce n’est pas le même code, mais même ainsi, je vous promets qu’une étude réfléchie de Rotor vous donnera de nouvelles idées sur les événements sous le capot du CLR. Mais veillez d’abord à passer en revue la licence SSCLI !)

La connaissance

Si vous aspirez à être chauffeur de taxi à Londres, vous devez d’abord gagner The Knowledge. Les étudiants étudient pendant de nombreux mois pour mémoriser les milliers de petites rues de Londres et apprendre les meilleurs itinéraires d’un endroit à l’autre. Et ils sortent tous les jours en scooter pour découvrir et renforcer leur apprentissage du livre.

De même, si vous souhaitez être un développeur de code managé hautes performances, vous devez acquérir the Managed Code Knowledge. Vous devez savoir ce que coûte chaque opération de bas niveau. Vous devez découvrir les fonctionnalités telles que les délégués et le coût de sécurité de l’accès au code. Vous devez découvrir les coûts des types et des méthodes que vous utilisez, ainsi que ceux que vous écrivez. Et il n’est pas mal de découvrir quelles méthodes peuvent être trop coûteuses pour votre application et donc de les éviter.

La Connaissance n’est dans aucun livre, hélas. Vous devez sortir sur votre scooter et explorer( c’est-à-dire, monter csc, ildasm, le débogueur VS.NET, le CLR Profiler, votre profileur, certains minuteurs perf, etc., et voir ce que votre code coûte en temps et en espace.

Vers un modèle de coût pour le code managé

Mis à part les préliminaires, considérons un modèle de coût pour le code managé. De cette façon, vous serez en mesure d’examiner une méthode feuille et de dire en un coup d’œil quelles expressions et instructions sont les plus coûteuses; et vous serez en mesure de faire des choix plus intelligents à mesure que vous écrivez du nouveau code.

(Cela ne permet pas de résoudre les coûts transitifs liés à l’appel de vos méthodes ou méthodes du .NET Framework. Il faudra attendre un autre article un autre jour.)

J’ai indiqué précédemment que la plupart du modèle de coût C s’applique toujours dans les scénarios C++. De même, une grande partie du modèle de coût C/C++ s’applique toujours au code managé.

Comment est-ce possible ? Vous connaissez le modèle d’exécution CLR. Vous écrivez votre code dans l’un des plusieurs langages. Vous le compilez au format CIL (Common Intermediate Language), empaqueté dans des assemblys. Vous exécutez l’assembly d’application main et il commence à exécuter le CIL. Mais n’est-ce pas un ordre de grandeur plus lent, comme les interpréteurs de bytecode d’autrefois ?

Compilateur juste-à-temps

Mais non. Le CLR utilise un compilateur JIT (juste-à-temps) pour compiler chaque méthode CIL en code x86 natif, puis exécute le code natif. Bien qu’il existe un petit délai pour la compilation JIT de chaque méthode telle qu’elle est appelée en premier, chaque méthode appelée exécute du code natif pur sans surcharge d’interprétation.

Contrairement à un processus de compilation C++ hors ligne traditionnel, le temps passé dans le compilateur JIT est un délai de « temps d’horloge murale », dans le visage de chaque utilisateur, de sorte que le compilateur JIT n’a pas le luxe de passer des passes d’optimisation exhaustives. Malgré cela, la liste des optimisations effectuées par le compilateur JIT est impressionnante :

  • Pliage constant
  • Constante et propagation de copie
  • Élimination des sous-expressions communes
  • Mouvement de code des invariants de boucle
  • Magasin mort et élimination du code mort
  • Inscrire l’allocation
  • Inlining de méthode
  • Déroulement des boucles (petites boucles avec de petits corps)

Le résultat est comparable au code natif traditionnel, au moins dans le même ballpark.

En ce qui concerne les données, vous allez utiliser une combinaison de types valeur ou de types de référence. Les types de valeurs, y compris les types intégraux, les types à virgule flottante, les énumérations et les structs, vivent généralement sur la pile. Ils sont aussi petits et rapides que les locaux et les structs en C/C++. Comme avec C/C++, vous devez probablement éviter de passer des structs volumineux comme arguments de méthode ou des valeurs de retour, car la surcharge de copie peut être prohibitive.

Les types de référence et les types de valeurs boxed se logent dans le tas. Ils sont traités par des références d’objet, qui sont simplement des pointeurs d’ordinateur tout comme des pointeurs d’objet en C/C++.

Ainsi, le code managé jitted peut être rapide. À quelques exceptions près que nous abordons ci-dessous, si vous avez une idée du coût d’une expression dans le code C natif, vous n’allez pas mal mal modéliser son coût comme équivalent dans le code managé.

Je devrais également mention NGEN, un outil qui compile « à l’avance » le CIL en assemblys de code natif. Bien que le NGEN’ing de vos assemblys n’ait actuellement pas d’impact substantiel (bon ou mauvais) sur le temps d’exécution, il peut réduire le nombre total d’ensembles de travail pour les assemblys partagés qui sont chargés dans de nombreux AppDomains et processus. (Le système d’exploitation peut partager une copie du code NGEN entre tous les clients ; alors que le code jitted n’est généralement pas partagé entre les appDomains ou les processus. Mais voir aussi LoaderOptimizationAttribute.MultiDomain.)

Automatic Memory Management

L’écart le plus significatif du code managé (par rapport au code natif) est la gestion automatique de la mémoire. Vous allouez de nouveaux objets, mais le garbage collector CLR (GC) les libère automatiquement pour vous lorsqu’ils deviennent inaccessibles. GC s’exécute de temps en temps, souvent de manière imperceptible, arrêtant généralement votre application pendant seulement une milliseconde ou deux, parfois plus longtemps.

Plusieurs autres articles traitent des implications du garbage collector sur les performances et nous ne les récapitulerons pas ici. Si votre application suit les recommandations de ces autres articles, le coût global du garbage collection peut être insignifiant, quelques pour cent du temps d’exécution, compétitif ou supérieur à l’objet new C++ traditionnel et delete. Le coût amorti de la création et de la récupération automatique ultérieure d’un objet est suffisamment faible pour que vous puissiez créer plusieurs dizaines de millions de petits objets par seconde.

Mais l’allocation d’objets n’est toujours pas gratuite. Les objets prennent de l’espace. L’allocation d’objets rampant conduit à des cycles de garbage collection plus fréquents.

Pire encore, conserver inutilement des références à des graphiques d’objets inutiles les maintient en vie. Nous voyons parfois des programmes modestes avec des ensembles de travail lamentables de plus de 100 Mo, dont les auteurs nient leur culpabilité et attribuent plutôt leurs performances médiocres à un problème mystérieux, non identifié (et donc insoluble) avec le code managé lui-même. C’est tragique. Mais une heure d’étude avec le profileur CLR et les modifications apportées à quelques lignes de code réduit leur utilisation du tas d’un facteur de dix ou plus. Si vous rencontrez un problème d’ensemble de travail volumineux, la première étape consiste à rechercher dans le miroir.

Ne créez donc pas d’objets inutilement. Juste parce que la gestion automatique de la mémoire dissipe les nombreuses complexités, tracas et bogues de l’allocation et de la libération d’objets, parce qu’elle est si rapide et si pratique, nous avons naturellement tendance à créer de plus en plus d’objets, comme s’ils poussent sur des arbres. Si vous souhaitez écrire du code managé très rapide, créez des objets de manière réfléchie et appropriée.

Cela s’applique également à la conception d’API. Il est possible de concevoir un type et ses méthodes afin d’exiger que les clients créent de nouveaux objets avec un abandon sauvage. Fais pas ça.

Coût des choses dans le code managé

Examinons maintenant le coût de temps de diverses opérations de code managé de bas niveau.

Le tableau 1 présente le coût approximatif d’une variété d’opérations de code managé de bas niveau, en nanosecondes, sur un PC Pentium-III de 1,1 GHz qui exécute Windows XP et .NET Framework v1.1 (« Everett ») réunis avec un ensemble de boucles de minutage simples.

Le pilote de test appelle chaque méthode de test, en spécifiant un nombre d’itérations à effectuer, mis à l’échelle automatiquement pour itérer entre 218 et 230 itérations, si nécessaire pour effectuer chaque test pendant au moins 50 ms. En règle générale, cela est suffisamment long pour observer plusieurs cycles de garbage collection de génération 0 dans un test qui effectue une allocation intense d’objets. Le tableau montre les résultats en moyenne sur 10 essais, ainsi que le meilleur essai (durée minimale) pour chaque sujet de test.

Chaque boucle de test est déployée 4 à 64 fois si nécessaire pour réduire la surcharge de la boucle de test. J’ai inspecté le code natif généré pour chaque test pour m’assurer que le compilateur JIT n’optimisait pas le test à l’extérieur. Par exemple, dans plusieurs cas, j’ai modifié le test pour conserver les résultats intermédiaires en direct pendant et après la boucle de test. De même, j’ai apporté des modifications pour empêcher l’élimination courante de la sous-expression dans plusieurs tests.

Tableau 1 Temps primitifs (moyenne et minimale) (ns)

Avg Min Primitives Avg Min Primitives Avg Min Primitives
0.0 0.0 Control 2.6 2.6 nouveau valtype L1 0,8 0,8 isinst up 1
1.0 1.0 Int add 4.6 4.6 nouveau valtype L2 0,8 0,8 isinst down 0
1.0 1.0 Int sub 6.4 6.4 new valtype L3 6.3 6.3 isinst down 1
2.7 2.7 Int mul 8.0 8.0 nouveau valtype L4 10,7 10.6 isinst (up 2) down 1
35,9 35,7 Int div 23.0 22,9 new valtype L5 6.4 6.4 isinst down 2
2.1 2.1 Int shift 22.0 20,3 new reftype L1 6.1 6.1 isinst down 3
2.1 2.1 ajout long 26,1 23,9 new reftype L2 1.0 1.0 obtenir le champ
2.1 2.1 long sub 30,2 27,5 new reftype L3 1.2 1.2 get prop
34,2 34,1 mule longue 34,1 30.8 nouveau reftype L4 1.2 1.2 définir le champ
50,1 50,0 long div 39.1 34.4 new reftype L5 1.2 1.2 set prop
5,1 5,1 décalage long 22,3 20,3 nouveau ctor vide reftype L1 0.9 0.9 obtenir ce champ
1.3 1.3 float add 26,5 23,9 nouveau ctor vide reftype L2 0.9 0.9 obtenir cet accessoire
1.4 1.4 float sub 38.1 34.7 nouveau reftype ctor vide L3 1.2 1.2 définir ce champ
2.0 2.0 mule flottante 34.7 30,7 nouveau reftype ctor vide L4 1.2 1.2 définir cet accessoire
27,7 27.6 float div 38.5 34.3 nouveau ctor vide reftype L5 6.4 6.3 obtenir une propriété virtuelle
1.5 1.5 double ajout 22,9 20,7 new reftype ctor L1 6.4 6.3 set virtual prop
1.5 1.5 double sub 27.8 25.4 new reftype ctor L2 6.4 6.4 barrière d’écriture
2.1 2,0 double mul 32,7 29,9 new reftype ctor L3 1,9 1,9 load int array elem
27,7 27.6 double div 37.7 34,1 nouveau ctor de reftype L4 1,9 1,9 store int array elem
0,2 0,2 appel statique inlined 43.2 39.1 new reftype ctor L5 2.5 2.5 load obj array elem
6.1 6.1 appel statique 28,6 26,7 new reftype ctor no-inl L1 16,0 16,0 store obj array elem
1.1 1.0 appel instance inclus 38.9 36,5 new reftype ctor no-inl L2 29,0 21,6 box int
6.8 6.8 appel instance 50.6 47.7 new reftype ctor no-inl L3 3,0 3,0 unbox int
0,2 0,2 inlined this inst call 61.8 58.2 new reftype ctor no-inl L4 41.1 40.9 appel délégué
6.2 6.2 cet appel instance 72.6 68.5 new reftype ctor no-inl L5 2.7 2.7 sum array 1000
5.4 5.4 appel virtuel 0,4 0,4 cast up 1 2.8 2.8 sum array 10000
5.4 5.4 cet appel virtuel 0.3 0.3 cast down 0 2,9 2.8 sum array 100000
6.6 6.5 appel d’interface 8,9 8.8 cast down 1 5.6 5.6 sum array 1000000
1.1 1.0 inst itf instance appeler 9.8 9.7 cast (up 2) down 1 3,5 3,5 sum list 1000
0,2 0,2 cet appel itf instance 8,9 8.8 cast down 2 6.1 6.1 sum list 10000
5.4 5.4 inst itf virtual call 8,7 8.6 cast down 3 22.0 22.0 sum list 100000
5.4 5.4 cet appel virtuel itf       21,5 21,4 sum list 1000000

Une clause d’exclusion de responsabilité : ne prenez pas ces données trop littéralement. Le test du temps est lourd de périls d’effets inattendus de second ordre. Un hasard peut placer le code jitté, ou certaines données cruciales, afin qu’il s’étende sur les lignes de cache, interfère avec quelque chose d’autre ou ce que vous avez. C’est un peu comme le principe d’incertitude : les temps et les différences de temps de 1 nanoseconde ou plus sont aux limites de l’observable.

Autre clause de non-responsabilité : ces données ne sont pertinentes que pour les scénarios de code et de données de petite taille qui tiennent entièrement dans le cache. Si les parties « chaudes » de votre application ne tiennent pas dans le cache sur puce, vous risquez de rencontrer un ensemble différent de problèmes de performances. Nous avons beaucoup plus à dire sur les caches à la fin du journal.

Et encore une autre clause de non-responsabilité : l’un des avantages sublimes de l’expédition de vos composants et applications en tant qu’assemblys de CIL est que votre programme peut automatiquement être plus rapide chaque seconde, et devenir plus rapide chaque année , « plus rapide chaque seconde », car le runtime peut (en théorie) réétérer le code compilé JIT pendant l’exécution de votre programme ; et « plus rapides à chaque année », car à chaque nouvelle version du runtime, des algorithmes plus performants, plus intelligents et plus rapides peuvent prendre une nouvelle mesure pour optimiser votre code. Ainsi, si quelques-uns de ces minutages semblent moins qu’optimaux dans .NET 1.1, sachez qu’ils devraient s’améliorer dans les versions ultérieures du produit. Il s’ensuit que toute séquence de code natif de code donnée signalée dans cet article peut changer dans les versions ultérieures du .NET Framework.

Mises à part les exclusions de responsabilité, les données fournissent une idée raisonnable des performances actuelles de diverses primitives. Les chiffres sont logiques, et ils corroborent mon assertion selon laquelle la plupart du code managé jitté s’exécute « près de la machine » comme le fait le code natif compilé. Les opérations primitives entières et flottantes sont rapides, les appels de méthode de différents types moins, mais (croyez-moi) sont toujours comparables à C/C++ natifs ; Et pourtant, nous constatons également que certaines opérations qui sont généralement bon marché dans le code natif (casts, magasins de tableaux et de champs, pointeurs de fonction (délégués)) sont désormais plus coûteuses. Pourquoi ? Voyons voir.

Opérations arithmétiques

Tableau 2 Temps d’opération arithmétique (ns)

Avg Min Primitives Avg Min Primitives
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int sub 1.4 1.4 sub float sub
2.7 2.7 int mul 2.0 2.0 mul float
35,9 35,7 int div 27,7 27.6 float div
2.1 2.1 int shift      
2.1 2.1 ajout long 1.5 1.5 double ajout
2.1 2.1 long sub 1.5 1.5 double sub
34,2 34,1 mule long 2.1 2,0 double mul
50,1 50,0 long div 27,7 27.6 double div
5,1 5,1 décalage long      

Autrefois, les mathématiques à virgule flottante étaient peut-être un ordre de grandeur plus lent que les mathématiques entières. Comme le montre le tableau 2, avec les unités à virgule flottante pipeline modernes, il semble qu’il y ait peu ou pas de différence. Il est étonnant de penser qu’un PC de notebook moyen est un ordinateur de classe gigaflop (pour les problèmes qui s’intègrent dans le cache).

Examinons une ligne de code jitted à partir des tests d’ajout d’entier et à virgule flottante :

Désassemblement 1 Int add et float add

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

Ici, nous voyons que le code jitté est proche de l’optimal. Dans le int add cas, le compilateur a même inscrit cinq des variables locales. Dans le cas de l’ajout float, j’ai été obligé de créer des variables a via h des statiques de classe pour vaincre l’élimination commune de la sous-expression.

Appels de méthode

Dans cette section, nous examinons les coûts et les implémentations des appels de méthode. Le sujet de test est une interface Id’implémentation de classe T , avec différentes sortes de méthodes. Voir La liste 1.

Liste 1 Méthodes de test d’appel de méthode

interface I { void itf1();… void itf5();… }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, …, int p) { }

    static void inl_s1() { } …    static void s1()     { if (falsePred) dummy(1, 2, 3, …, 16); } …    void inl_i1()        { } …    void i1()            { if (falsePred) dummy(1, 2, 3, …, 16); } …    public virtual void v1() { } …    void itf1()          { } …    virtual void itf5()  { } …}

Examinez le tableau 3. Il semble, à une première approximation, qu’une méthode soit inline (l’abstraction ne coûte rien) ou non (l’abstraction coûte >5X une opération entière). Il ne semble pas y avoir de différence significative dans le coût brut d’un appel statique, d’un appel instance, d’un appel virtuel ou d’un appel d’interface.

Table 3 Méthode Call Times (ns)

Avg Min Primitives Appelé Avg Min Primitives Appelé
0,2 0,2 appel statique inlined inl_s1 5.4 5.4 appel virtuel v1
6.1 6.1 appel statique s1 5.4 5.4 cet appel virtuel v1
1.1 1.0 appel de instance en ligne inl_i1 6.6 6.5 appel d’interface itf1
6.8 6.8 instance appel i1 1.1 1.0 inst itf instance appel itf1
0,2 0,2 inlined this inst call inl_i1 0,2 0,2 cet appel itf instance itf1
6.2 6.2 cet appel instance i1 5.4 5.4 inst itf virtual call itf5
        5.4 5.4 cet appel virtuel itf itf5

Toutefois, ces résultats sont des meilleurs cas non représentatifs, l’effet de l’exécution de boucles de minutage serré des millions de fois. Dans ces cas de test, les sites d’appel de méthode virtuelle et d’interface sont monomorphes (par exemple, par site d’appel, la méthode cible ne change pas au fil du temps), de sorte que la combinaison de la mise en cache de la méthode virtuelle et des mécanismes de répartition de la méthode d’interface (la table de méthode et les pointeurs et entrées de la carte d’interface) et la prédiction de branche providente spectaculairement providente permet au processeur d’effectuer un travail d’appel irréaliste par le biais de ces méthodes autrement difficiles à prédire, branches dépendantes des données. Dans la pratique, une absence de cache de données sur l’une des données du mécanisme de répartition, ou une mauvaise prédiction de branche (qu’il s’agit d’une absence de capacité obligatoire ou d’un site d’appel polymorphe), peut et ralentira les appels d’interface et virtuels de dizaines de cycles.

Examinons de plus près chacune de ces heures d’appel de méthode.

Dans le premier cas, appel statique incorporé, nous appelons une série de méthodes s1_inl() statiques vides, etc. Étant donné que le compilateur inline complètement tous les appels, nous finissent par chronométrer une boucle vide.

Pour mesurer le coût approximatif d’un appel de méthode statique, nous rendons les méthodes s1() statiques, etc. si volumineuses qu’elles ne sont pas rentables à intégrer dans l’appelant.

Observez que nous devons même utiliser une variable falsePredde faux prédicat explicite. Si nous écrivions

static void s1() { if (false) dummy(1, 2, 3, …, 16); }

Le compilateur JIT éliminerait l’appel mort à dummy et inline le corps entier de la méthode (désormais vide) comme précédemment. En passant, ici, une partie des 6,1 ns de temps d’appel doit être attribuée au test de prédicat (false) et sauter dans la méthode s1statique appelée . (En passant, un meilleur moyen de désactiver l’inlining est l’attribut CompilerServices.MethodImpl(MethodImplOptions.NoInlining) .)

La même approche a été utilisée pour l’appel instance inclus et le minutage des appels instance réguliers. Toutefois, étant donné que la spécification du langage C# garantit que tout appel sur une référence d’objet Null lève une exception NullReferenceException, chaque site d’appel doit s’assurer que le instance n’est pas null. Pour ce faire, vous pouvez déréférencer la référence instance ; si elle est null, elle génère une erreur qui est transformée en cette exception.

Dans Désassemble 2, nous utilisons une variable t statique comme instance, car lorsque nous avons utilisé une variable locale

    T t = new T();

le compilateur a hissé le instance case activée null hors de la boucle.

Site d’appel de méthode d’instance 2 avec instance null « case activée »

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

Les cas de l’appel de ce instance inclus et de cet appel instance sont les mêmes, sauf que le instance est this; ici, la case activée null a été supprimée.

Désassemble 3 Ce site d’appel de méthode instance

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

Les appels de méthode virtuelle fonctionnent comme dans les implémentations C++ traditionnelles. L’adresse de chaque méthode virtuelle nouvellement introduite est stockée dans un nouvel emplacement de la table de méthodes du type. La table de méthode de chaque type dérivé est conforme à et étend celle de son type de base, et tout remplacement de méthode virtuelle remplace l’adresse de méthode virtuelle du type de base par l’adresse de méthode virtuelle du type dérivé dans l’emplacement correspondant dans la table de méthode du type dérivé.

Sur le site d’appel, un appel de méthode virtuelle entraîne deux charges supplémentaires par rapport à un appel instance, l’une pour extraire l’adresse de la table de méthode (toujours disponible sur *(this+0)) et l’autre pour extraire l’adresse de méthode virtuelle appropriée à partir de la table de méthode et l’appeler. Voir Désassemble 4.

Site d’appel de méthode virtuelle 4

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

Enfin, nous arrivons aux appels de méthode d’interface (Désassemble 5). Celles-ci n’ont pas d’équivalent exact en C++. Un type donné peut implémenter un nombre quelconque d’interfaces, et chaque interface nécessite logiquement sa propre table de méthodes. Pour distribuer sur une méthode d’interface, nous recherchons la table de méthode, son mappage d’interface, l’entrée de l’interface dans cette carte, puis nous appelons indirect via l’entrée appropriée dans la section de l’interface de la table de méthode.

Site d’appel de méthode d’interface Désassemble 5

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

Le reste des minutages primitifs, inst itf instance call, this itf instance call, inst itf virtual call, this itf virtual call mettent en évidence l’idée que chaque fois que la méthode d’un type dérivé implémente une méthode d’interface, elle reste appelable via un site d’appel de méthode instance.

Par exemple, pour le test de cet appel itf instance, un appel sur une implémentation de méthode d’interface via une référence instance (pas d’interface), la méthode d’interface est correctement insérée et le coût passe à 0 ns. Même une implémentation de méthode d’interface est potentiellement inlineable lorsque vous l’appelez en tant que méthode instance.

Appels aux méthodes encore à jitted

Pour les appels de méthode statiques et instance (mais pas les appels de méthode d’interface et virtuels), le compilateur JIT génère actuellement différentes séquences d’appels de méthode selon que la méthode cible a déjà été jitée au moment où son site d’appel est en cours.

Si l’appelé (méthode cible) n’a pas encore été jitted, le compilateur émet un appel indirect par le biais d’un pointeur initialisé avec un « stub prejit ». Le premier appel sur la méthode cible arrive au stub, ce qui déclenche la compilation JIT de la méthode, la génération de code natif et la mise à jour du pointeur pour traiter le nouveau code natif.

Si l’appelé a déjà été jitted, son adresse de code native est connue, de sorte que le compilateur émet un appel direct à celui-ci.

Création d’un nouvel objet

La création d’un nouvel objet se compose de deux phases : l’allocation d’objets et l’initialisation d’objet.

Pour les types de référence, les objets sont alloués sur le tas garbage collectionné. Pour les types valeur, qu’ils soient résidents de la pile ou incorporés dans un autre type de référence ou de valeur, l’objet de type valeur se trouve à un décalage constant par rapport à la structure englobante, aucune allocation n’est requise.

Pour les objets de type référence de petite taille typiques, l’allocation de tas est très rapide. Après chaque garbage collection, sauf en présence d’objets épinglés, les objets en direct du tas de génération 0 sont compactés et promus en génération 1, de sorte que l’allocateur de mémoire dispose d’une belle arène de mémoire libre contiguë à utiliser. La plupart des allocations d’objets impliquent uniquement un incrément de pointeur et des limites case activée, ce qui est moins cher que l’allocateur de liste libre C/C++ standard (malloc/operator new). Le récupérateur de mémoire prend même en compte la taille du cache de votre machine pour essayer de conserver les objets gen 0 dans la zone rapide de la hiérarchie cache/mémoire.

Étant donné que le style de code managé préféré consiste à allouer la plupart des objets avec des durées de vie courtes et à les récupérer rapidement, nous incluons également (dans le coût de temps) le coût amorti du garbage collection de ces nouveaux objets.

Notez que le récupérateur de mémoire ne passe pas de temps à pleurer les objets morts. Si un objet est mort, GC ne le voit pas, ne le marche pas, ne lui donne pas de pensée d’une nanoseconde. GC ne s’intéresse qu’au bien-être des vivants.

(Exception : les objets morts finalisables sont un cas spécial. GC les suit et promeut spécialement les objets finalisables morts vers la prochaine génération en attente de finalisation. Cela est coûteux et, dans le pire des cas, peut promouvoir transitivement des graphiques d’objets morts volumineux. Par conséquent, ne rendez pas les objets finalisables, sauf si cela est strictement nécessaire; et si vous le devez, envisagez d’utiliser le modèle De suppression, en appelant GC.SuppressFinalizer quand c’est possible.) Sauf si votre méthode l’exige Finalize , ne conservez pas les références de votre objet finalisable à d’autres objets.

Bien entendu, le coût de gc amorti d’un objet volumineux de courte durée est supérieur au coût d’un petit objet de courte durée. Chaque allocation d’objets nous rapproche beaucoup du prochain cycle de garbage collection ; les objets plus grands le font beaucoup plus tôt que les petits. Tôt (ou tard), le moment des calculs viendra. Les cycles GC, en particulier les collections de génération 0, sont très rapides, mais ne sont pas gratuits, même si la grande majorité des nouveaux objets sont morts : pour trouver (marquer) les objets en direct, il est d’abord nécessaire de suspendre les threads, puis de parcourir les piles et autres structures de données pour collecter les références d’objet racine dans le tas.

(Peut-être plus significativement, moins d’objets plus grands tiennent dans la même quantité de cache que les objets plus petits. Les effets d’absence de cache peuvent facilement dominer les effets de longueur du chemin de code.)

Une fois l’espace alloué à l’objet, il reste à l’initialiser (le construire). Le CLR garantit que toutes les références d’objet sont préinitialisées à null, et que tous les types scalaires primitifs sont initialisés à 0, 0,0, false, etc. (Par conséquent, il n’est pas nécessaire de le faire de manière redondante dans vos constructeurs définis par l’utilisateur. N’hésitez pas, bien sûr. Mais n’oubliez pas que le compilateur JIT n’optimise pas nécessairement vos magasins redondants.)

En plus de mettre à zéro instance champs, le CLR initialise (types référence uniquement) les champs d’implémentation internes de l’objet : le pointeur de table de méthode et le mot d’en-tête de l’objet, qui précèdent le pointeur de table de méthode. Les tableaux obtiennent également un champ Length, et les tableaux d’objets obtiennent des champs Length et des champs de type d’élément.

Ensuite, le CLR appelle le constructeur de l’objet, le cas échéant. Le constructeur de chaque type, qu’il soit défini par l’utilisateur ou généré par le compilateur, appelle d’abord le constructeur de son type de base, puis exécute l’initialisation définie par l’utilisateur, le cas échéant.

En théorie, cela peut être coûteux pour les scénarios d’héritage profond. Si E étend D étend C étend B étend A (étend System.Object), l’initialisation d’un E entraîne toujours cinq appels de méthode. Dans la pratique, les choses ne sont pas si mauvaises, car le compilateur s’éloigne (dans le néant) appelle des constructeurs de type de base vides.

En se référant à la première colonne du tableau 4, observez que nous pouvons créer et initialiser un struct D avec quatre champs int en environ 8 int-add-times. Le désassemble 6 est le code généré à partir de trois boucles de minutage différentes, créant A, C et E. (Dans chaque boucle, nous modifions chaque nouvelle instance, ce qui empêche le compilateur JIT d’optimiser tout.)

Temps de création d’objet valeur et type de référence (ns) dans le tableau 4

Avg Min Primitives Avg Min Primitives Avg Min Primitives
2.6 2.6 new valtype L1 22.0 20,3 new reftype L1 22,9 20,7 new rt ctor L1
4.6 4.6 new valtype L2 26,1 23,9 new reftype L2 27.8 25.4 new rt ctor L2
6.4 6.4 new valtype L3 30,2 27,5 new reftype L3 32,7 29,9 new rt ctor L3
8.0 8.0 nouveau valtype L4 34,1 30.8 nouveau reftype L4 37.7 34,1 new rt ctor L4
23.0 22,9 new valtype L5 39.1 34.4 new reftype L5 43.2 39.1 new rt ctor L5
      22,3 20,3 new rt ctor vide L1 28,6 26,7 new rt no-inl L1
      26,5 23,9 new rt ctor vide L2 38.9 36,5 new rt no-inl L2
      38.1 34.7 new rt ctor vide L3 50.6 47.7 new rt no-inl L3
      34.7 30,7 new rt ctor vide L4 61.8 58.2 new rt no-inl L4
      38.5 34.3 new rt ctor vide L5 72.6 68.5 new rt no-inl L5

Désassemble 6 Construction d’objets de type valeur

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

Les cinq minutages suivants (nouveau reftype L1, ... new reftype L5) sont pour cinq niveaux d’héritage de types A référence, ..., E, sans constructeurs définis par l’utilisateur :

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

En comparant les temps de type de référence aux temps de type valeur, nous constatons que l’allocation amortie et le coût de libération de chaque instance est d’environ 20 ns (20 fois plus de temps supplémentaire int) sur la machine de test. C’est rapide : allocation, initialisation et récupération d’environ 50 millions d’objets de courte durée par seconde, soutenus. Pour les objets aussi petits que cinq champs, l’allocation et la collection ne comptent que pour la moitié du temps de création de l’objet. Voir Désassemble 7.

Construction d’objet de type référence 7

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

Les trois derniers ensembles de cinq minutages présentent des variations sur ce scénario de construction de classe héritée.

  1. New rt ctor vide L1, ..., new rt empty ctor L5: Chaque type A, ..., E a un constructeur défini par l’utilisateur vide. Ils sont tous inclus et le code généré est le même que celui ci-dessus.

  2. New rt ctor L1, ..., new rt ctor L5: Chaque type A, ..., E a un constructeur défini par l’utilisateur qui définit sa variable instance sur 1 :

        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

Le compilateur inline chaque ensemble de constructeurs de classe de base imbriquée appelle le new site. (Désassemble 8).

Désassemblement 8 Constructeurs hérités profondément incorporés

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. New rt no-inl L1, ..., new rt no-inl L5: Chaque type A, ..., E a un constructeur défini par l’utilisateur qui a été écrit intentionnellement pour être trop coûteux pour être inline. Ce scénario simule le coût de création d’objets complexes avec des hiérarchies d’héritage profondes et des constructeurs largish.

      public class A     { int a; public A() { a = 1; if (falsePred) dummy(…); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
    

Les cinq derniers minutages du tableau 4 montrent la surcharge supplémentaire liée à l’appel des constructeurs de base imbriqués.

Interlude: CLR Profiler Demo

Maintenant, pour une démonstration rapide du CLR Profiler. Le profileur CLR, anciennement connu sous le nom de profileur d’allocation, utilise les API de profilage DU CLR pour collecter des données d’événement, en particulier les événements d’appel, de retour et d’allocation d’objets et de garbage collection, à mesure que votre application s’exécute. (Le profileur CLR est un profileur « invasif », ce qui signifie qu’il ralentit malheureusement considérablement l’application profilée.) Une fois les événements collectés, vous utilisez CLR Profiler pour explorer l’allocation de mémoire et le comportement gc de votre application, y compris l’interaction entre votre graphique d’appels hiérarchique et vos modèles d’allocation de mémoire.

CLR Profiler est utile d’apprendre, car pour de nombreuses applications de code managé « à performances défiées », la compréhension de votre profil d’allocation de données fournit les insights critiques nécessaires pour réduire votre ensemble de travail et fournir ainsi des composants et des applications rapides et économes.

Le profileur CLR peut également révéler quelles méthodes allouent plus de stockage que prévu, et peut révéler des cas où vous conservez par inadvertance des références à des graphiques d’objets inutiles qui, autrement, pourraient être récupérés par GC. (Un modèle de conception de problème courant est un cache logiciel ou une table de choix d’éléments qui ne sont plus nécessaires ou peuvent être reconstitués ultérieurement en toute sécurité. C’est tragique lorsqu’un cache maintient les graphiques d’objets en vie au-delà de leur durée de vie utile. Veillez plutôt à annuler les références aux objets dont vous n’avez plus besoin.)

La figure 1 est une vue chronologie du tas pendant l’exécution du pilote de test de minutage. Le modèle en scie indique l’allocation de plusieurs milliers d’instances d’objets C (magenta), D (violet) et E (bleu). Toutes les quelques millisecondes, nous mâchons environ 150 Ko de RAM dans le nouveau tas d’objet (génération 0), et le récupérateur de mémoire s’exécute brièvement pour le recycler et promouvoir les objets en direct vers la génération 1. Il est remarquable que même dans cet environnement de profilage (lent) envahissant, dans l’intervalle de 100 ms (2,8 s à 2,9s), nous subissant environ 8 cycles GC de génération 0. Ensuite, à 2,977 s, faisant de la place pour un autre E instance, le récupérateur de mémoire effectue un garbage collection de génération 1, qui collecte et compacte le tas de génération 1 , et ainsi le scitooth continue, à partir d’une adresse de départ inférieure.

Figure1 Affichage de la ligne de temps du profileur CLR

Notez que plus l’objet est grand (E plus grand que D supérieur à C), plus le tas gen 0 se remplit rapidement et plus le cycle GC est fréquent.

Casts et vérifications de type d’instance

La base fondamentale du code managé sûr, sécurisé et vérifiable est la sécurité de type. S’il était possible de convertir un objet en un type qu’il n’est pas, il serait facile de compromettre l’intégrité du CLR et de le faire ainsi à la merci du code non approuvé.

Tableau 5 Cast et isinst Times (ns)

Avg Min Primitives Avg Min Primitives
0,4 0,4 cast up 1 0,8 0,8 isinst up 1
0.3 0.3 cast down 0 0,8 0,8 isinst down 0
8,9 8.8 cast down 1 6.3 6.3 isinst down 1
9.8 9.7 cast (up 2) down 1 10,7 10.6 isinst (up 2) down 1
8,9 8.8 cast down 2 6.4 6.4 isinst down 2
8,7 8.6 cast down 3 6.1 6.1 isinst down 3

Le tableau 5 indique la surcharge de ces vérifications de type obligatoires. Un cast d’un type dérivé en type de base est toujours sûr et gratuit ; tandis qu’un cast d’un type de base en type dérivé doit être vérifié par type.

Une cast (cochée) convertit la référence d’objet en type cible ou lève InvalidCastException.

En revanche, l’instruction isinst CIL est utilisée pour implémenter les mot clé C# as :

bac = ac as B;

Si ac n’est pas B ou dérivé de B, le résultat est null, pas une exception.

La liste 2 illustre l’une des boucles de minutage de cast, et Désassemblement 9 montre le code généré pour une conversion vers le bas en un type dérivé. Pour effectuer le cast, le compilateur émet un appel direct à une routine d’assistance.

Liste 2 Boucle pour tester le minutage de cast

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
    }
}

Désassemblement 9 vers le bas

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

Propriétés

Dans le code managé, une propriété est une paire de méthodes, un getter de propriété et un jeu de propriétés, qui agissent comme un champ d’un objet. La méthode get_ extrait la propriété ; la méthode set_ met à jour la propriété vers une nouvelle valeur.

En outre, les propriétés se comportent et coûtent, tout comme les méthodes instance standard et les méthodes virtuelles. Si vous utilisez une propriété pour extraire ou stocker simplement un champ instance, elle est généralement incorporée, comme avec toute méthode de petite taille.

Le tableau 6 indique le temps nécessaire pour extraire (et ajouter) et stocker un ensemble d’entiers instance champs et propriétés. Le coût d’obtention ou de définition d’une propriété est en effet identique à l’accès direct au champ sous-jacent, sauf si la propriété est déclarée virtuelle, auquel cas le coût est approximativement celui d’un appel de méthode virtuelle. Pas de surprise là-bas.

Heure des champs et des propriétés du tableau 6 (ns)

Avg Min Primitives
1.0 1.0 champ get
1.2 1.2 get prop
1.2 1.2 champ set
1.2 1.2 set prop
6.4 6.3 obtenir un prop virtuel
6.4 6.3 set virtual prop

Barrières d’écriture

Le garbage collector CLR tire bon parti de l'« hypothèse générationnelle » (la plupart des nouveaux objets meurent jeunes) pour réduire la surcharge de collecte.

Le tas est logiquement partitionné en générations. Les objets les plus récents vivent dans la génération 0 (gen 0). Ces objets n’ont pas encore survécu à une collection. Au cours d’une collection gen 0, GC détermine quels objets gen 0 sont accessibles, le cas échéant, à partir de l’ensemble racine GC, qui comprend des références d’objets dans les registres d’ordinateurs, sur la pile, les références d’objet de champ statique de classe, etc. Les objets accessibles de manière transitive sont « actifs » et promus (copiés) vers la génération 1.

Étant donné que la taille totale du tas peut être de plusieurs centaines de Mo, alors que la taille du tas gen 0 ne peut être que de 256 Ko, la limitation de l’étendue du traçage du graphe d’objets du GC au tas gen 0 est une optimisation essentielle pour atteindre les temps de pause de collection très brefs du CLR.

Toutefois, il est possible de stocker une référence à un objet gen 0 dans un champ de référence d’objet d’un objet gen 1 ou gen 2. Étant donné que nous n’examinons pas d’objets gen 1 ou gen 2 au cours d’une collection gen 0, si c’est la seule référence à l’objet gen 0 donné, cet objet pourrait être récupéré à tort par GC. On ne peut pas laisser ça se produire !

Au lieu de cela, tous les magasins dans tous les champs de référence d’objet dans le tas entraînent une barrière d’écriture. Il s’agit d’un code de comptabilité qui note efficacement les magasins de références d’objets de nouvelle génération dans des champs d’objets de génération antérieure. Ces anciens champs de référence d’objet sont ajoutés à l’ensemble racine GC des GC suivants.

La surcharge de la barrière d’écriture par objet-référence-champ-magasin est comparable au coût d’un appel de méthode simple (tableau 7). Il s’agit d’une nouvelle dépense qui n’est pas présente dans le code C/C++ natif, mais il s’agit généralement d’un petit prix à payer pour l’allocation d’objets ultra-rapide et le GC, et les nombreux avantages de productivité de la gestion automatique de la mémoire.

Tableau 7 Temps de barrière d’écriture (ns)

Avg Min Primitives
6.4 6.4 barrière d’écriture

Les barrières d’écriture peuvent être coûteuses dans les boucles internes étroites. Mais dans les années à venir, nous pouvons nous attendre à des techniques de compilation avancées qui réduisent le nombre d’obstacles à l’écriture et le coût total amorti.

Vous pouvez penser que les barrières d’écriture sont uniquement nécessaires sur les magasins pour les champs de référence d’objet de types de référence. Toutefois, dans une méthode de type valeur, les magasins dans ses champs de référence d’objet (le cas échéant) sont également protégés par des barrières d’écriture. Cela est nécessaire, car le type value lui-même peut parfois être incorporé dans un type de référence résidant dans le tas.

Accès à l’élément array

Pour diagnostiquer et empêcher les erreurs hors limites du tableau et les altérations de tas, et pour protéger l’intégrité du CLR lui-même, les chargements et les magasins d’éléments de tableau sont vérifiés, garantissant que l’index se trouve dans l’intervalle [0,array. Longueur 1] inclusive ou levée IndexOutOfRangeException.

Nos tests mesurent le temps de chargement ou de stockage des éléments d’un int[] tableau et d’un A[] tableau. (Tableau 8).

Tableau 8 Temps d’accès au tableau (ns)

Avg Min Primitives
1,9 1,9 load int array elem
1,9 1,9 store int array elem
2.5 2.5 load obj array elem
16,0 16,0 store obj array elem

Les limites case activée nécessitent la comparaison de l’index du tableau au tableau implicite. Champ Longueur. Comme le montre Désassemblement 10, dans seulement deux instructions, nous case activée l’index n’est ni inférieur à 0 ni supérieur ou égal au tableau. Longueur : si c’est le cas, nous nous branchons à une séquence hors ligne qui lève l’exception. Il en est de même pour les charges d’éléments de tableau d’objets et pour les magasins dans des tableaux d’ints et d’autres types de valeurs simples. (Load obj array elem time est (insignifiant) plus lent en raison d’une légère différence dans sa boucle interne.)

Désassemblement 10 élément de tableau int

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

Grâce à ses optimisations de la qualité du code, le compilateur JIT élimine souvent les vérifications de limites redondantes.

En rappelant les sections précédentes, nous pouvons nous attendre à ce que les magasins d’éléments de tableau d’objets soient considérablement plus coûteux. Pour stocker une référence d’objet dans un tableau de références d’objets, le runtime doit :

  1. case activée’index de tableau est dans des limites ;
  2. case activée objet est un instance du type d’élément tableau ;
  3. effectuer une barrière d’écriture (notant toute référence d’objet intergénérationnel du tableau à l’objet ).

Cette séquence de code est assez longue. Au lieu de l’émettre à chaque site de magasin de tableau d’objets, le compilateur émet un appel à une fonction d’assistance partagée, comme indiqué dans Désassemblement 11. Cet appel, plus ces trois actions, compte pour le temps supplémentaire nécessaire dans ce cas.

Désassemblement 11 Élément de tableau d’objets Store

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

Conversion boxing et unboxing

Un partenariat entre les compilateurs .NET et le CLR permet aux types valeurs, y compris les types primitifs comme int (System.Int32), de participer comme s’ils étaient des types de référence, à traiter en tant que références d’objets. Cette affordance( ce sucre syntaxique) permet de passer des types valeur aux méthodes en tant qu’objets, de les stocker dans des collections en tant qu’objets, etc.

Pour « box », un type valeur consiste à créer un objet de type référence qui contient une copie de son type valeur. Cela est conceptuellement identique à la création d’une classe avec un champ instance sans nom du même type que le type valeur.

Pour « unbox », un type de valeur boxed consiste à copier la valeur, à partir de l’objet, dans une nouvelle instance du type valeur.

Comme le montre le tableau 9 (par rapport au tableau 4), le temps amorti nécessaire à la mise en boîte d’un int, puis au nettoyage de la mémoire, est comparable au temps nécessaire pour instancier une petite classe avec un champ int.

Table 9 Box et Unbox int Times (ns)

Avg Min Primitives
29,0 21,6 boîte int
3,0 3,0 unbox int

Pour annuler la boîte de réception d’un objet int en boîte, un cast explicite en int est nécessaire. Cette opération est compilée en une comparaison du type de l’objet (représenté par son adresse de table de méthode) et de l’adresse de table de méthode int boxed. Si elles sont égales, la valeur est copiée à partir de l’objet. Sinon, une exception est levée. Voir Désassemblement 12.

Désassemblement 12 Box et unbox int

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

Délégués

En C, un pointeur vers la fonction est un type de données primitif qui stocke littéralement l’adresse de la fonction.

C++ ajoute des pointeurs aux fonctions membres. Un pointeur vers la fonction membre (PMF) représente un appel de fonction membre différé. L’adresse d’une fonction de membre non virtuel peut être une adresse de code simple, mais l’adresse d’une fonction membre virtuelle doit contenir un appel de fonction membre virtuel particulier. La déréférence d’un tel PMF est un appel de fonction virtuelle.

Pour déréférencer un pmf C++, vous devez fournir une instance :

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

Il y a des années, au sein de l’équipe de développement du compilateur Visual C++, nous avions l’habitude de nous demander quel type de bête est l’expression pa->*pmf nue (opérateur d’appel de fonction) ? Nous l’avons appelé pointeur lié à la fonction membre , mais l’appel de fonction membre latent est tout aussi approprié.

Pour revenir à la terre de code managé, un objet délégué est simplement cela : un appel de méthode latent. Un objet délégué représente à la fois la méthode à appeler et l’instance pour l’appeler, ou pour un délégué à une méthode statique, uniquement la méthode statique à appeler.

(Comme indiqué dans notre documentation : une déclaration de délégué définit un type de référence qui peut être utilisé pour encapsuler une méthode avec une signature spécifique. Un délégué instance encapsule une méthode statique ou instance. Les délégués sont à peu près similaires aux pointeurs de fonction en C++; toutefois, les délégués sont sécurisés et sécurisés de type.)

Les types délégués en C# sont des types dérivés de MulticastDelegate. Ce type fournit une sémantique riche, notamment la possibilité de créer une liste d’appels de paires (objet,méthode) à appeler lorsque vous appelez le délégué.

Les délégués fournissent également une fonctionnalité pour l’appel de méthode asynchrone. Après avoir défini un type de délégué et instancié un, initialisé avec un appel de méthode latente, vous pouvez l’appeler de manière synchrone (syntaxe d’appel de méthode) ou de manière asynchrone, via BeginInvoke. Si BeginInvoke est appelé, le runtime met en file d’attente l’appel et retourne immédiatement à l’appelant. La méthode cible est appelée ultérieurement, sur un thread de pool de threads.

Toutes ces sémantiques riches ne sont pas bon marché. En comparant le tableau 10 et le tableau 3, notez que l’appel de délégué est ** environ huit fois plus lent qu’un appel de méthode. Attendez-vous à ce que cela s’améliore au fil du temps.

Tableau 10 Temps d’appel des délégués (ns)

Avg Min Primitives
41.1 40.9 appel de délégué

Des erreurs de cache, des erreurs de page et de l’architecture de l’ordinateur

Dans le « bon vieux temps », vers 1983, les processeurs étaient lents (environ 5 millions d’instructions/s), et relativement parlant, la RAM était assez rapide, mais petite (environ 300 ns temps d’accès sur 256 Ko de DRAM), et les disques étaient lents et volumineux (temps d’accès d’environ 25 ms sur des disques de 10 Mo). Les microprocesseurs PC étaient des CISC scalaires, la plupart des points flottants se trouvaient dans les logiciels et il n’y avait pas de caches.

Après vingt années supplémentaires de la loi de Moore, vers 2003, les processeurs sont rapides (3 opérations par cycle à 3 GHz), la RAM est relativement très lente (environ 100 ns temps d’accès sur 512 Mo de DRAM) et les disques sont très lents et énormes (environ 10 ms temps d’accès sur des disques de 100 Go). Les microprocesseurs PC sont désormais hors d’ordre du flux de données superscalaire hyperthreading trace-cache RISC (exécutant des instructions CISC décodées) et il existe plusieurs couches de caches. Par exemple, un certain microprocesseur orienté serveur a un cache de données de niveau 1 de 32 Ko (peut-être 2 cycles de latence), 512 Ko de cache de données L2 et 2 Mo de cache de données L3 (peut-être une douzaine de cycles de latence). tout sur puce.

Dans le bon vieux temps, vous pouviez, et parfois le faisait, compter les octets de code que vous avez écrits et compter le nombre de cycles que le code devait exécuter. Un chargement ou un magasin a pris à peu près le même nombre de cycles qu’un ajout. Le processeur moderne utilise la prédiction de branche, la spéculation et l’exécution dans le désordre (flux de données) sur plusieurs unités de fonction pour rechercher le parallélisme au niveau de l’instruction et ainsi progresser sur plusieurs fronts à la fois.

Maintenant, nos PC les plus rapides peuvent émettre jusqu’à ~9 000 opérations par microseconde, mais dans cette même microseconde, chargez ou stockez uniquement dans DRAM ~10 lignes de cache. Dans les cercles d’architecture de l’ordinateur, cela est connu comme frapper le mur de mémoire. Les caches masquent la latence de la mémoire, mais uniquement jusqu’à un point. Si le code ou les données ne tiennent pas dans le cache et/ou présentent une mauvaise localité de référence, notre jet supersonique de 9 000 opérations par microseconde dégénère en un tricycle de 10 charges par microseconde.

Et (ne laissez pas cela arriver à vous) si l’ensemble de travail d’un programme dépasse la RAM physique disponible, et que le programme commence à prendre des erreurs de page matérielle, puis dans chaque service d’erreur de page de 10 000 microsecondes (accès au disque), nous manquons l’occasion de rapprocher l’utilisateur de 90 millions d’opérations de sa réponse. C’est tellement horrible que je crois que vous allez à partir de ce jour prendre soin de mesurer votre ensemble de travail (vadump) et utiliser des outils comme CLR Profiler pour éliminer les allocations inutiles et les rétentions involontaires de graphiques d’objets.

Mais qu’est-ce que tout cela a à voir avec la connaissance du coût des primitives de code managé ?Tout*.*

Rappelant le tableau 1, la liste générale des temps primitifs du code managé, mesurées sur un P-III de 1,1 GHz, observe que chaque fois, même le coût amorti de l’allocation, de l’initialisation et de la récupération d’un objet de cinq champs avec cinq niveaux d’appels de constructeur explicites est plus rapide qu’un seul accès DRAM. Une seule charge qui manque tous les niveaux de cache sur puce peut prendre plus de temps que presque n’importe quelle opération de code managé.

Par conséquent, si vous êtes passionné par la vitesse de votre code, il est impératif de prendre en compte et de mesurer la hiérarchie cache/mémoire lors de la conception et de l’implémentation de vos algorithmes et structures de données.

Temps nécessaire pour une démonstration simple : est-il plus rapide de additionner un tableau d’ints ou de additionner une liste liée équivalente d’ints ? Lesquels, combien, et pourquoi ?

Pensez-y pendant une minute. Pour les petits éléments tels que les ints, l’empreinte mémoire par élément de tableau est d’un quart de celle de la liste liée. (Chaque nœud de liste liée a deux mots de surcharge d’objet et deux mots de champs (lien suivant et élément int).) Cela va nuire à l’utilisation du cache. Notez un pour l’approche de tableau.

Toutefois, la traversée de tableau peut entraîner une limite de tableau case activée par élément. Vous venez de voir que les limites case activée prennent un peu de temps. Peut-être que cela pointe les échelles en faveur de la liste liée?

Désassemble 13 Sum int array versus sum int linked list

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

En se référant à Désassemble 13, j’ai empilé le jeu en faveur de la traversée de liste liée, le déroutant quatre fois, même en supprimant le pointeur null habituel fin-of-list case activée. Chaque élément de la boucle de tableau nécessite six instructions, tandis que chaque élément de la boucle de liste liée n’a besoin que d’instructions 11/4 = 2,75. Maintenant, qu’est-ce qui est le plus rapide ?

Conditions de test : tout d’abord, créez un tableau d’un million d’ints et une liste liée simple et traditionnelle d’un million d’ints (1 M de nœuds de liste). Ensuite, le temps nécessaire pour additionner les 1 000 premiers, 10 000, 10 000, 100 000 et 1 000 000 éléments. Répétez chaque boucle plusieurs fois, pour mesurer le comportement de cache le plus flatteur pour chaque cas.

Qu’est-ce qui est plus rapide ? Après avoir deviné, reportez-vous aux réponses : les huit dernières entrées du tableau 1.

Intéressant. Les temps sont sensiblement plus lents à mesure que les données référencées augmentent plus que les tailles de cache successives. La version du tableau est toujours plus rapide que la version de liste liée, même si elle exécute deux fois plus d’instructions ; pour 100 000 éléments, la version du tableau est sept fois plus rapide !

Pourquoi ? Tout d’abord, moins d’éléments de liste liée tiennent dans un niveau donné de cache. Tous ces en-têtes et liens d’objet gaspillent de l’espace. Deuxièmement, notre processeur de flux de données moderne en désordre peut potentiellement effectuer un zoom avant et progresser sur plusieurs éléments du tableau en même temps. En revanche, avec la liste liée, tant que le nœud de liste actuel n’est pas dans le cache, le processeur ne peut pas commencer à extraire le lien suivant vers le nœud après cela.

Dans le cas de 100 000 éléments, le processeur passe (en moyenne) environ (22-3,5)/22 = 84 % de son temps à virer ses pouces en attendant que la ligne de cache d’un nœud de liste soit lue à partir de la mémoire DRAM. Ça sonne mal, mais les choses pourraient être bien pires. Étant donné que les éléments de liste liés sont petits, beaucoup d’entre eux tiennent sur une ligne de cache. Étant donné que nous parcourons la liste dans l’ordre d’allocation et que le récupérateur de mémoire conserve l’ordre d’allocation même en compactant les objets morts hors du tas, il est probable, après avoir extrait un nœud sur une ligne de cache, que les plusieurs nœuds suivants sont maintenant également dans le cache. Si les nœuds étaient plus grands, ou si les nœuds de liste étaient dans un ordre d’adresse aléatoire, chaque nœud visité peut bien être un cache complet. L’ajout de 16 octets à chaque nœud de liste double le temps de traversée par élément à 43 ns ; +32 octets, 67 ns/élément ; et l’ajout de 64 octets double à nouveau, à 146 ns/élément, probablement la latence DRAM moyenne sur l’ordinateur de test.

Quelle est la leçon à retenir ici ? Évitez les listes liées de 100 000 nœuds ? Non. La leçon est que les effets du cache peuvent dominer n’importe quelle considération de faible niveau d’efficacité du code managé par rapport au code natif. Si vous écrivez du code managé critique pour les performances, en particulier du code gérant des structures de données volumineuses, gardez à l’esprit les effets du cache, réfléchissez à vos modèles d’accès à la structure de données et recherchez des empreintes de données plus petites et une bonne localité de référence.

En passant, la tendance est que le mur de mémoire, le ratio de temps d’accès DRAM divisé par le temps d’opération du processeur, continuera de s’aggraver au fil du temps.

Voici quelques règles de « conception consciente du cache » :

  • Expérimentez et mesurez vos scénarios, car il est difficile de prédire les effets de second ordre et parce que les règles de base ne valent pas le papier sur lequel elles sont imprimées.
  • Certaines structures de données, illustrées par des tableaux, utilisent l’adjacence implicite pour représenter une relation entre les données. D’autres, illustrées par des listes liées, utilisent des pointeurs explicites (références) pour représenter la relation. L’adjacence implicite est généralement préférable : l'« implicite » permet d’économiser de l’espace par rapport aux pointeurs ; et l’adjacence fournissent une localité de référence stable, et peuvent permettre au processeur de commencer plus de travail avant de poursuivre le pointeur suivant.
  • Certains modèles d’utilisation privilégient les structures hybrides : listes de petits tableaux, tableaux de tableaux ou arbres B.
  • Peut-être que les algorithmes de planification sensibles à l’accès au disque, conçus à l’arrière lorsque les accès au disque ne coûtent que 50 000 instructions de processeur, devraient être recyclés maintenant que les accès DRAM peuvent prendre des milliers d’opérations processeur.
  • Étant donné que le récupérateur de mémoire compact et de marquage CLR conserve l’ordre relatif des objets, les objets alloués ensemble dans le temps (et sur le même thread) ont tendance à rester ensemble dans l’espace. Vous pourrez peut-être utiliser ce phénomène pour colocaliser de manière réfléchie des données cliquish sur les lignes de cache courantes.
  • Vous souhaiterez peut-être partitionner vos données dans des parties chaudes qui sont fréquemment parcourues et qui doivent tenir dans le cache, et des parties froides qui sont rarement utilisées et qui peuvent être « mises en cache ».

Expériences de temps à faire soi-même

Pour les mesures de minutage dans ce document, j’ai utilisé le compteur QueryPerformanceCounter de performances haute résolution Win32 (et QueryPerformanceFrequency).

Ils sont facilement appelés via P/Invoke :

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

Vous appelez QueryPerformanceCounter juste avant et juste après votre boucle de minutage, soustrayez les nombres, multipliez par 1,0e9, divisez par fréquence, divisez par nombre d’itérations, et il s’agit de votre heure approximative par itération en ns.

En raison des restrictions d’espace et de temps, nous n’avons pas couvert le verrouillage, la gestion des exceptions ou le système de sécurité d’accès au code. Considérez-le comme un exercice pour le lecteur.

En passant, j’ai produit les désassembles dans cet article à l’aide de la fenêtre de désassemblement dans VS.NET 2003. Il y a un truc à cela, cependant. Si vous exécutez votre application dans le débogueur VS.NET, même en tant qu’exécutable optimisé intégré en mode Release, il sera exécuté en « mode débogage » dans lequel les optimisations telles que l’incorporation sont désactivées. La seule façon que j’ai trouvée pour obtenir un aperçu du code natif optimisé émis par le compilateur JIT était de lancer mon application de test en dehors du débogueur, puis de s’y attacher à l’aide de Debug.Processes.Attach.

Un modèle de coût d’espace ?

Ironiquement, les considérations relatives à l’espace empêchent une discussion approfondie de l’espace. Quelques paragraphes brefs, alors.

Considérations de bas niveau (plusieurs étant C# (typeAttributes.SequentialLayout par défaut) et spécifiques à x86) :

  • La taille d’un type valeur correspond généralement à la taille totale de ses champs, avec des champs de 4 octets ou plus petits alignés sur leurs limites naturelles.
  • Il est possible d’utiliser [StructLayout(LayoutKind.Explicit)] des attributs et [FieldOffset(n)] pour implémenter des unions.
  • La taille d’un type référence est de 8 octets plus la taille totale de ses champs, arrondie à la limite de 4 octets suivante, et avec des champs de 4 octets ou plus petits alignés sur leurs limites naturelles.
  • En C#, les déclarations d’énumération peuvent spécifier un type de base intégral arbitraire (à l’exception de char). Il est donc possible de définir des énumérations 8 bits, 16 bits, 32 bits et 64 bits.
  • Comme dans C/C++, vous pouvez souvent raser quelques dizaines de pour cent de l’espace d’un objet plus grand en dimensionnant vos champs intégraux de manière appropriée.
  • Vous pouvez inspecter la taille d’un type de référence alloué avec clr Profiler.
  • Les objets volumineux (plusieurs dizaines de Ko ou plus) sont gérés dans un tas d’objets volumineux distinct, pour éviter toute copie coûteuse.
  • Les objets finalisables utilisent une génération gc supplémentaire à récupérer: utilisez-les avec parcimonie et envisagez d’utiliser le modèle De suppression.

Considérations relatives à la vue d’ensemble :

  • Chaque Domaine d’application entraîne actuellement une surcharge d’espace importante. De nombreuses structures de runtime et d’infrastructure ne sont pas partagées entre appDomains.
  • Dans un processus, le code jitté n’est généralement pas partagé entre appDomains. Si le runtime est spécifiquement hébergé, il est possible de remplacer ce comportement. Consultez la documentation pour CorBindToRuntimeEx et l’indicateur STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN .
  • En tout état de cause, le code jitté n’est pas partagé entre les processus. Si vous avez un composant qui sera chargé dans de nombreux processus, envisagez de précompiler avec NGEN pour partager le code natif.

Réflexion

Il a été dit que « si vous devez vous demander ce que la réflexion coûte, vous ne pouvez pas vous le permettre ». Si vous l’avez lu jusqu’ici, vous savez à quel point il est important de vous demander quel est le coût et de mesurer ces coûts.

La réflexion est utile et puissante, mais par rapport au code natif jitted, elle n’est ni rapide ni petite. On t’a prévenu. Mesurez-le pour vous-même.

Conclusion

Vous savez maintenant (plus ou moins) ce que coûte le code managé au niveau le plus bas. Vous disposez maintenant des connaissances de base nécessaires pour faire des compromis d’implémentation plus intelligents et écrire du code managé plus rapide.

Nous avons vu que le code managé jitté peut être aussi « pédale vers le métal » que le code natif. Votre défi consiste à coder judicieusement et à choisir judicieusement parmi les nombreuses installations riches et faciles à utiliser dans le framework

Il existe des paramètres où les performances n’ont pas d’importance, et des paramètres où il s’agit de la fonctionnalité la plus importante d’un produit. L’optimisation prématurée est la racine de tout mal. Mais il en va de même de l’inattention imprudente à l’efficacité. Vous êtes un professionnel, un artiste, un artisan. Veillez donc à connaître le coût des choses. Si vous ne le savez pas ou même si vous pensez le faire, mesurez-le régulièrement.

Comme pour l’équipe CLR, nous continuons à travailler pour fournir une plateforme qui est sensiblement plus productive que le code natif et qui est cependant plus rapide que le code natif. Attendez-vous à ce que les choses s’ent de mieux en mieux. Tenez-vous informé.

N’oubliez pas votre promesse.

Ressources