Écriture de code managé plus rapide : connaître les coûts des choses
Jan Gray
Microsoft CLR Performance Team
Juin 2003
S’applique à :
Microsoft® .NET Framework
Résumé : Cet article présente un modèle de coût de bas niveau 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 rapide. (30 pages imprimées)
Téléchargez ledu profileur CLR
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 de nombreuses façons d’implémenter un calcul, et certains sont beaucoup mieux que d’autres : plus simple, plus propre, plus facile à gérer. Certaines façons sont très rapides et certains sont étonnamment lents.
Ne perpétrez pas de code lent et gras sur le monde. N’oubliez-vous pas de mépriser ce code ? Code qui s’exécute dans des ajustements et démarre ? Code qui verrouille l’interface utilisateur pendant quelques secondes à la fois ? Code qui met en place l’UC ou thrashe le disque ?
Fais pas ça. Au lieu de cela, tenez-vous et engagez-vous avec moi :
« Je promets que je ne vais pas expédier le code lent. La vitesse est une fonctionnalité dont je me soucie. Chaque jour, je vais prêter attention aux performances de mon code. Je vais régulièrement et méthodiquement mesurer sa vitesse et sa taille. Je vais apprendre, construire ou acheter les outils dont j’ai besoin pour le faire. C’est ma responsabilité.
(Vraiment.) Alors avez-vous promis ? C’est bien.
Alors, comment vous écrivez le code le plus rapide et le plus serré jour après jour ? C’est une question de choisir consciemment la façon frugale de préférence à la façon extravagante, ballonnée, encore et encore, et une question de penser à travers les conséquences. Toute page donnée de code capture des dizaines de ces petites décisions.
Mais vous ne pouvez pas faire de choix intelligents 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 qui coûte.
C’était plus facile dans les bons vieux jours. Bon programmeur C savait. Chaque opérateur et opération en C, qu’il s’agit d’affectation, d’entier ou de calcul à virgule flottante, de déréférencement ou d’appel de fonction, mappé plusieurs à un à un à une seule opération de machine primitive. Vrai, parfois plusieurs instructions de machine étaient requises pour placer les opérandes appropriés dans les registres appropriés, et parfois une seule instruction pouvait capturer plusieurs opérations C (célèbrement *dest++ = *src++;
), mais vous pouviez généralement écrire (ou lire) une ligne de code C et savoir où le temps allait. 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 non plus.)
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, le secteur logiciel PC a fait une transition de C à C++.
C++ est un super-ensemble de C et est « pay as you go » (les nouvelles fonctionnalités ne coûtent rien si vous ne les utilisez pas), c’est pourquoi l’expertise en programmation C, y compris le modèle de coût internalisé, est directement applicable. Si vous prenez du code C opérationnel et recompilez-le pour C++, le temps d’exécution et la surcharge d’espace ne doivent pas changer beaucoup.
En revanche, C++ introduit de nombreuses nouvelles fonctionnalités de langage, notamment les constructeurs, les destructeurs, les nouveaux, la suppression, l’héritage unique, multiple et virtuel, 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 les compositions de même, ce qui entraîne des coûts masqués non trivials. Par exemple, les fonctions virtuelles coûtent deux indirections supplémentaires par appel et ajoutent un champ de pointeur vtable masqué à chaque instance. Ou considérez que ce code innocueux :
{ complex a, b, c, d; … a = b + c * d; }
compile en environ treize appels de fonction membre implicites (espérons inclus).
Il y a neuf ans, nous avons exploré ce sujet dans mon article C++ : Under the Hood. J’ai écrit :
« Il est important de comprendre comment votre langage de programmation est implémenté. Ces connaissances dissipent la peur et se demandent « Qu’est-ce que le compilateur fait ici ? » ; donne confiance pour utiliser les nouvelles fonctionnalités ; et fournit des insights lors du débogage et de l’apprentissage d’autres fonctionnalités linguistiques. Il donne également une idée des coûts relatifs des différents choix de codage qui sont nécessaires pour écrire le code le plus efficace jour à jour.
Maintenant, nous allons examiner de façon similaire le code managé. Cet article explore les temps et les coûts d’espacement de l’exécution managée, afin que nous puissiez faire des compromis plus intelligents dans notre codage quotidien.
Et gardez nos promesses.
Pourquoi le code managé ?
Pour la grande majorité des développeurs de code natif, le code managé est une plateforme plus productive et plus productive pour exécuter leurs logiciels. Il supprime des catégories entières de bogues, telles que les altérations de tas et les erreurs de tableau-index-out-of-bound qui entraînent souvent des sessions de débogage frustrantes de fin de nuit. 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 vieillissement de Win32/COM/COM/MFC/VB, le .NET Framework est une conception d’ardoise propre rafraîchissante, où vous pouvez faire plus d’efforts avec moins d’efforts.
Pour votre communauté d’utilisateurs, le code managé permet d’obtenir des applications plus riches et plus robustes, mieux vivre grâce à de meilleurs logiciels.
Qu’est-ce que le secret pour écrire du code managé plus rapide ?
Juste parce que vous pouvez faire plus avec moins d’efforts n’est pas une licence pour abdiquer votre responsabilité de coder sagement. 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 des terres de code managé. Nous apprenons toujours les cordes, y compris ce qui coûte.
Quand il s’agit du .NET Framework riche et pratique, c’est comme si nous sommes des enfants dans le magasin de bonbons. "Wow, je n’ai pas à faire tout ce truc 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 brûler des mégaoctets d’infosets XML d’analyse de RAM pour extraire quelques éléments d’entre eux. En C ou C++, il était si douloureux que vous pensez deux fois, peut-être que vous générez un ordinateur d’état sur une API de type SAX. Avec .NET Framework, vous chargez simplement l’ensemble d’informations dans un gulp. Peut-être que tu le fais encore et plus. Alors peut-être que votre application ne semble plus si rapide. Peut-être qu’il a un ensemble de travail de plusieurs mégaoctets. Peut-être que vous devriez avoir pensé 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 termes de performances des types et méthodes du Framework, mais 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 à couvrir ou à documenter ; mais encore, le « ne sait pas » rend cela beaucoup plus difficile pour nous de prendre des décisions éclairées.
Étant donné que nous sommes tous des débutants ici, et puisque nous ne savons pas quels sont les coûts, et puisque les coûts ne sont pas clairement documentés, que faisons-nous ?
Mesurez-le. Le secret consiste à mesurer son et à être vigilant. Nous allons tous devoir prendre l’habitude de mesurer le coût des choses. Si nous allons à la difficulté de mesurer les coûts des choses, nous ne serons pas ceux qui appellent par inadvertance une nouvelle méthode qui coûte dix fois ce que nous supposés coûts.
(En passant, pour obtenir des informations plus approfondies sur les performances sous-jacentes de la BCL (bibliothèque de classes de base) ou le CLR lui-même, envisagez d’examiner les cli de source partagée, a.k.a. Rotor. Le code rotor partage une ligne de sang avec le .NET Framework et le CLR. Ce n’est pas le même code tout au long, mais même si, je vous promets qu’une étude réfléchie de Rotor vous donnera de nouveaux insights sur les événements sous le capot du CLR. Mais veillez à passer en revue la licence SSCLI en premier !)
Les connaissances
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 à Londres et apprendre les meilleurs itinéraires de l’endroit à l’endroit. Et ils sortent tous les jours sur les scooters pour scouter autour 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 La base de connaissances du code managé. Vous devez découvrir les coûts de 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 apprendre les coûts des types et méthodes que vous utilisez, et ceux que vous écrivez. Et il ne fait pas de mal à découvrir quelles méthodes peuvent être trop coûteuses pour votre application, et donc les éviter.
La Connaissance n’est pas dans un livre, hélas. Vous devez sortir sur votre scooter de et explorer, c’est-à-dire monter le csc, ildasm, le débogueur VS.NET, le profileur CLR, votre profileur, certains minuteurs perf, etc. et voir ce que votre code coûte dans le temps et l’espace.
Vers un modèle de coût pour le code managé
Préliminaires de côté, prenons 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 d’indiquer en un coup d’œil quelles expressions et instructions sont plus coûteuses ; et vous serez en mesure de faire des choix plus intelligents lorsque vous écrivez du nouveau code.
(Cela ne traite pas des coûts transitifs liés à l’appel de vos méthodes ou méthodes du .NET Framework. Cela devra 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 cela peut-il être ? 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 principal et commence à exécuter la bibliothèque CIL. Mais n’est-ce pas un ordre de grandeur plus lent, comme les interpréteurs bytecode de vieux ?
Compilateur juste-à-temps
Mais non. Le CLR utilise un compilateur JIT (juste-à-temps) pour compiler chaque méthode dans CIL en code x86 natif, puis exécute le code natif. Bien qu’il y ait un petit délai pour la compilation JIT de chaque méthode, car elle est appelée pour la première fois, chaque méthode appelée exécute du code natif pur sans surcharge interprétative.
Contrairement à un processus traditionnel de compilation C++ hors ligne, le temps passé dans le compilateur JIT est un délai « temps d’horloge mur », dans le visage de chaque utilisateur, de sorte que le compilateur JIT ne dispose pas du luxe d’optimisation exhaustive. Même si la liste des optimisations effectuées par le compilateur JIT est impressionnante :
- Pliage constant
- Propagation constante et copie
- Élimination des sous-expressions courantes
- Mouvement de code des invariants de boucle
- Suppression du magasin mort et du code mort
- Inscrire l’allocation
- Inlining de méthode
- Déroulement de boucles (petites boucles avec de petits corps)
Le résultat est comparable au code natif traditionnel ,au moins dans le mêmede ballpark .
Quant aux données, vous allez utiliser un mélange de types valeur ou de types référence. Les types valeur, 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 sont en C/C++. Comme avec C/C++, vous devez probablement éviter de passer des structs volumineux en tant qu’arguments de méthode ou valeurs de retour, car la surcharge de copie peut être prohibitivement coûteuse.
Les types référence et les types valeur boxed vivent dans le tas. Elles sont traitées par des références d’objet, qui sont simplement des pointeurs d’ordinateur comme des pointeurs d’objet en C/C++.
Ainsi, le code managé jitted peut être rapide. Avec quelques exceptions que nous abordons ci-dessous, si vous avez une idée du coût d’une expression dans le code C natif, vous ne serez pas loin de modéliser son coût comme équivalent dans le code managé.
Je devrais également mentionner NGEN, un outil qui « à l’avance » compile le code CIL en assemblys de code natifs. Bien que NGEN’ing vos assemblys n’ait actuellement pas d’impact important (bon ou incorrect) sur le temps d’exécution, il peut réduire le nombre total de travail défini pour les assemblys partagés chargés dans de nombreux appDomains et processus. (Le système d’exploitation peut partager une copie du code NGEN’d sur tous les clients, tandis que le code jitted n’est généralement pas partagé entre appDomains ou processus. Mais voir également LoaderOptimizationAttribute.MultiDomain
.)
Gestion automatique de la mémoire
Le départ le plus important du code managé (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 maintenant et à nouveau, souvent imperceptiblement, arrêtant généralement votre application pendant seulement une milliseconde ou deux, parfois plus longtemps.
Plusieurs autres articles traitent des implications en matière de performances du garbage collector et nous ne les récapitulons pas ici. Si votre application suit les recommandations de ces autres articles, le coût global du garbage collection peut être négligeable, quelques pour cent du temps d’exécution, concurrentiels ou supérieurs à l’objet C++ traditionnel new
et delete
. Le coût amorti de la création et de la récupération automatique automatique d’un objet est suffisamment faible que vous pouvez créer plusieurs dizaines de millions d’objets de petite taille par seconde.
Mais l’allocation d’objets n’est toujours pas gratuite. Les objets occupent de l’espace. L’allocation d’objets rampant entraîne des cycles de garbage collection plus fréquents.
Bien pire, retenir inutilement les références aux graphiques d’objets inutiles les maintient en vie. Nous voyons parfois des programmes modestes avec des ensembles de travail de 100 Mo lamentables, dont les auteurs refusent leur culpabilité et attribuent plutôt leurs mauvaises performances à un problème mystérieux, non identifié (et donc inductible) avec le code managé lui-même. C’est tragique. Mais ensuite, une heure d’étude avec le CLR Profiler 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 de jeu de travail volumineux, la première étape consiste à regarder dans le miroir.
Par conséquent, ne créez pas inutilement des objets. Tout simplement parce que la gestion automatique de la mémoire dissipe les nombreuses complexités, les soucis et les bogues de l’allocation d’objets et de la libération, parce qu’il est si rapide et si pratique, nous avons naturellement tendance à créer plus et 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 qu’ils nécessitent clients de créer de nouveaux objets avec abandon sauvage. Fais pas ça.
Coût des choses dans le code managé
Examinons maintenant le coût temporel des différentes 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 »), collecté avec un ensemble de boucles de minutage simples.
Le pilote de test appelle chaque méthode de test, en spécifiant un certain nombre d’itérations à effectuer, mises à 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 assez 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 présente les résultats moyenés sur 10 essais, ainsi que la meilleure (durée minimale) d’essai pour chaque sujet de test.
Chaque boucle de test est annulée 4 à 64 fois si nécessaire pour réduire la surcharge de boucle de test. J’ai inspecté le code natif généré pour chaque test pour vous assurer que le compilateur JIT n’a pas optimisé le test à distance( 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 des sous-expressions courantes dans plusieurs tests.
Table 1 Primitive Times (moyenne et minimale) (ns)
Avg | Min | Primitif | Avg | Min | Primitif | Avg | Min | Primitif |
---|---|---|---|---|---|---|---|---|
0.0 | 0.0 | Contrôle | 2.6 | 2.6 | new valtype L1 | 0.8 | 0.8 | isinst up 1 |
1.0 | 1.0 | Ajouter int | 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 | Mul int | 8.0 | 8.0 | new 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 | nouveau reftype L1 | 6.1 | 6.1 | isinst down 3 |
2.1 | 2.1 | ajout long | 26.1 | 23.9 | nouveau reftype L2 | 1.0 | 1.0 | obtenir le champ |
2.1 | 2.1 | long sub | 30.2 | 27.5 | nouveau 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 | nouveau reftype L5 | 1.2 | 1.2 | set prop |
5.1 | 5.1 | décalage long | 22.3 | 20.3 | new reftype empty ctor L1 | 0.9 | 0.9 | obtenir ce champ |
1.3 | 1.3 | float add | 26.5 | 23.9 | new reftype empty ctor L2 | 0.9 | 0.9 | obtenir cet accessoire |
1.4 | 1.4 | float sub | 38.1 | 34.7 | new reftype empty ctor L3 | 1.2 | 1.2 | définir ce champ |
2.0 | 2.0 | mul flottante | 34.7 | 30.7 | new reftype empty ctor L4 | 1.2 | 1.2 | définir cet accessoire |
27.7 | 27.6 | float div | 38.5 | 34.3 | new reftype empty ctor L5 | 6.4 | 6.3 | get virtual prop |
1.5 | 1.5 | double ajout | 22.9 | 20.7 | new reftype ctor L1 | 6.4 | 6.3 | définir une propriété virtuelle |
1.5 | 1.5 | double sub | 27.8 | 25.4 | nouveau reftype ctor L2 | 6.4 | 6.4 | barrière d’écriture |
2.1 | 2.0 | double mule | 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 reftype ctor L4 | 1.9 | 1.9 | stocker 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 d’instance inline | 38.9 | 36.5 | new reftype ctor no-inl L2 | 29.0 | 21.6 | boîte int |
6.8 | 6.8 | appel d’instance | 50.6 | 47.7 | new reftype ctor no-inl L3 | 3.0 | 3.0 | unbox int |
0.2 | 0.2 | inlined this 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 d’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 vers le bas 0 | 2.9 | 2.8 | sum array 100000 |
6.6 | 6.5 | appel d’interface | 8.9 | 8.8 | cast vers le bas 1 | 5.6 | 5.6 | sum array 1000000 |
1.1 | 1.0 | appel d’instance itf inst | 9.8 | 9.7 | cast (jusqu’à 2) vers le bas 1 | 3.5 | 3.5 | sum list 1000 |
0.2 | 0.2 | cet appel d’instance itf | 8.9 | 8.8 | cast down 2 | 6.1 | 6.1 | sum list 10000 |
5.4 | 5.4 | appel virtuel itf inst | 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. Les tests de temps sont lourds avec le danger des effets inattendus de deuxième ordre. Une chance de se produire peut placer le code jitted, ou certaines données cruciales, afin qu’elle couvre 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 ainsi sont aux limites de l’observable.
Autre exclusion de responsabilité : ces données ne sont pertinentes que pour les petits scénarios de code et de données qui s’intègrent entièrement dans le cache. Si les parties « chaudes » de votre application ne s’intègrent pas dans le cache à puce, vous pouvez avoir un ensemble différent de défis de performances. Nous avons beaucoup plus à dire sur les caches près de la fin du papier.
Et encore une autre exclusion de 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 obtenir plus rapidement chaque seconde, et obtenir plus rapidement chaque année — « plus rapidement chaque seconde », car le runtime peut (en théorie) réinscrire le code compilé JIT au fur et à mesure que votre programme s’exécute ; et « plus rapidement toute l’année », car avec chaque nouvelle version du runtime, mieux, plus intelligent, plus rapide algorithmes peuvent prendre un nouveau coup de poignard pour optimiser votre code. Par conséquent, si quelques-uns de ces minutages semblent moins que optimaux dans .NET 1.1, prenez le cœur qu’ils devraient améliorer dans les versions ultérieures du produit. Il suit que toute séquence de code native donnée signalée dans cet article peut changer dans les versions ultérieures du .NET Framework.
En dehors des clauses d’exclusion de responsabilité, les données fournissent un sentiment d’intestin raisonnable pour les performances actuelles de diverses primitives. Les nombres sont logiques, et ils justifient mon assertion que la plupart des codes managés jitted s’exécutent « près de l’ordinateur », tout comme 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 (faites-moi confiance) sont toujours comparables à C/C++ natifs ; Et pourtant, nous voyons également que certaines opérations qui sont généralement bon marché dans le code natif (casts, tableaux et magasins de champs, pointeurs de fonction (délégués)) sont désormais plus coûteuses. Pourquoi? Voyons.
Opérations arithmétiques
Table 2 Temps d’opération arithmétique (ns)
Avg | Min | Primitif | Avg | Min | Primitif |
---|---|---|---|---|---|
1.0 | 1.0 | int add | 1.3 | 1.3 | float add |
1.0 | 1.0 | int sub | 1.4 | 1.4 | float sub |
2.7 | 2.7 | mule int | 2.0 | 2.0 | mul flottante |
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 longue | 2.1 | 2.0 | double mule |
50.1 | 50.0 | long div | 27.7 | 27.6 | double div |
5.1 | 5.1 | décalage long |
Dans les vieux jours, 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 des unités à virgule flottante pipeline modernes, il semble qu’il n’y a pas ou peu de différence. Il est étonnant de penser qu’un PC de notebook moyen est une machine de classe gigaflop maintenant (pour les problèmes qui s’intègrent dans le cache).
Examinons une ligne de code jitted à partir de l’entier et du virgule flottante ajouter des tests :
Désassemble 1 Int add and 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 jitted est proche d’optimal. Dans le cas int add
, le compilateur a même inscrit cinq des variables locales. Dans le cas d’ajout float, j’ai été obligé de faire des variables a
par h
statiques de classe pour vaincre l’élimination de sous-expression commune.
Appels de méthode
Dans cette section, nous examinons les coûts et les implémentations des appels de méthode. L’objet de test est une classe T
l’implémentation de l’interface I
, avec différentes sortes de méthodes. Consultez la liste 1.
Listing 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() { } …}
Considérez le tableau 3. Il apparaît, à une première approximation, une méthode est insérée (l’abstraction ne coûte rien) ou non (les coûts d’abstraction >5X une opération entière). Il n’y a pas de différence significative dans le coût brut d’un appel statique, d’un appel d’instance, d’un appel virtuel ou d’un appel d’interface.
Avg | Min | Primitif | Appelé | Avg | Min | Primitif | 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 d’instance inline | inl_i1 |
6.6 | 6.5 | appel d’interface | itf1 |
6.8 | 6.8 | appel d’instance | i1 |
1.1 | 1.0 | appel d’instance itf inst | itf1 |
0.2 | 0.2 | inlined this inlined this inst call | inl_i1 |
0.2 | 0.2 | cet appel d’instance itf | itf1 |
6.2 | 6.2 | cet appel d’instance | i1 |
5.4 | 5.4 | appel virtuel itf inst | itf5 |
5.4 | 5.4 | cet appel virtuel itf | itf5 |
Toutefois, ces résultats ne sont pas réprésentatifs meilleurs cas, l’effet de l’exécution de boucles de minutage serrées 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 des mécanismes de distribution de méthode et de méthode d’interface (la table de méthodes et les entrées de carte d’interface) et la prédiction spectaculaire de branche fournissent le processeur pour effectuer un appel irréaliste efficace par le biais de ces méthodes difficiles à prédire, branches dépendantes des données. En pratique, un cache de données manque sur l’une des données du mécanisme de distribution 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 virtuels et d’interface par des dizaines de cycles.
Examinons de plus près chacune de ces heures d’appel de méthode.
Dans le premier cas, appel statique inline, nous appelons une série de méthodes statiques vides s1_inl()
etc. Étant donné que le compilateur supprime complètement tous les appels, nous finissent par temporisation d’une boucle vide.
Pour mesurer le coût approximatif d’un appel de méthode statique , nous rendons les méthodes statiques s1()
etc. si volumineuses qu’elles ne sont pas rentables à inliner dans l’appelant.
Observez que nous devons même utiliser une variable de prédicat false explicite falsePred
. Si nous avons écrit
static void s1() { if (false) dummy(1, 2, 3, …, 16); }
le compilateur JIT élimine l’appel mort à dummy
et inline le corps de la méthode entière (maintenant vide) comme précédemment. Par la façon, voici quelques-uns des 6,1 ns de temps d’appel doivent être attribués au test de prédicat (false) et sauter dans la méthode statique appelée s1
. (Par la façon, une meilleure façon de désactiver l’inlining est l’attribut CompilerServices.MethodImpl(MethodImplOptions.NoInlining)
.)
La même approche a été utilisée pour l’appel d’instance inline et le minutage des appels d’instance standard. 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 l’instance n’est pas null. Cette opération est effectuée en déreferencant la référence de l’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 statique t
comme instance, car lorsque nous avons utilisé une variable locale
T t = new T();
le compilateur a hissé l’instance null à extraire de la boucle.
Site d’appel de méthode d’instance Désassemble 2 avec une instance null
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 des inline cet appel d’instance et cet appel d’instance sont identiques, sauf que l’instance est this
; ici, la vérification null a été supprimée.
Désassembler 3 Ce site d’appel de méthode d’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 dans la table de méthode du type. La table de méthodes de chaque type dérivé est conforme à celle de son type de base et toute substitution 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 d’instance, une pour extraire l’adresse de la table de méthode (toujours trouvée à *(this+0)
), et une 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.
Désassembler 4 site d’appel de méthode virtuelle
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 à méthode d’interface appelle (Désassemble 5). Ils n’ont pas d’équivalent exact en C++. Tout type donné peut implémenter n’importe quel nombre d’interfaces, et chaque interface nécessite logiquement sa propre table de méthodes. Pour distribuer une méthode d’interface, nous recherchons la table de méthodes, son mappage d’interface, l’entrée de l’interface dans cette carte, puis appelons indirect par le biais d’une entrée appropriée dans la section de l’interface de la table de méthodes.
Désassembler 5 le site d’appel de méthode d’interface
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, d’appel d’instance itf, cet appel d’instance itf, l’appel virtuel itf le plus inst itf, cet appel virtuel itf mettre 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 appelante via un site d’appel de méthode d’instance.
Par exemple, pour le test cet appel d’instance itf, un appel sur une implémentation de méthode d’interface via une référence d’instance (et non d’interface), la méthode d’interface est correctement incorporée et le coût passe à 0 ns. Même une implémentation de méthode d’interface est potentiellement inline quand vous l’appelez en tant que méthode d’instance.
Appels aux méthodes pourtant jitted
Pour les appels de méthode statiques et d’instance (mais pas les appels de méthode virtuelle et d’interface), 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é jittée au moment où son site d’appel est en cours de jitted.
Si l’appelé (méthode cible) n’a pas encore été jitted, le compilateur émet un appel indirect via un pointeur qui est d’abord initialisé avec un « stub prejit ». Le premier appel à 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 afin que le compilateur émet un appel direct à celui-ci.
Nouvelle création d’objets
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 collecté par le garbage. Pour les types valeur, qu’ils soient résidents de pile ou incorporés dans un autre type référence ou valeur, l’objet type valeur se trouve à un décalage constant de la structure englobante, aucune allocation n’est requise.
Pour les petits objets de type référence 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 à la génération 1, et donc l’allocateur de mémoire a une belle arène de mémoire libre contiguë agréable à utiliser. La plupart des allocations d’objets impliquent uniquement un incrément de pointeur et une vérification des limites, ce qui est moins cher que l’allocateur de liste libre C/C++ classique (malloc/opérateur nouveau). Le garbage collector prend même en compte la taille du cache de votre ordinateur pour essayer de conserver les objets gen 0 dans le point doux 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 temps coût) le coût amorti du garbage collection de ces nouveaux objets.
Notez que le garbage collector 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 une pensée nanoseconde. GC ne s’inquiète que du bien-être de la vie.
(Exception : les objets morts finalisables sont un cas spécial. GC effectue le suivi de ces objets, 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 de manière transitive de grands graphiques d’objets morts. Par conséquent, ne rendez pas les objets finalisables, sauf si strictement nécessaire ; et si vous devez, envisagez d’utiliser le modèle de suppression , en appelant GC.SuppressFinalizer
lorsque cela est possible.) Sauf si votre méthode Finalize
l’exige, ne conservez pas les références de votre objet finalisable à d’autres objets.
Bien sûr, le coût GC amorti d’un grand objet 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 plus du prochain cycle de garbage collection ; les plus grands objets le font beaucoup plus tôt que les petits. Tôt (ou tard), le moment de la considération viendra. Les cycles GC, en particulier les collections de génération 0, sont très rapides, mais ne sont pas libres, même si la grande majorité des nouveaux objets sont morts : pour rechercher (marquer) les objets actifs, il est d’abord nécessaire de suspendre les threads, puis de parcourir les piles et d’autres structures de données pour collecter des références d’objets racines dans le tas.
(Peut-être plus considérablement, moins d’objets plus volumineux tiennent dans la même quantité de cache que les objets plus petits. Les effets de manque de cache peuvent facilement dominer les effets de longueur du chemin de code.)
Une fois l’espace alloué à l’objet, il reste à l’initialiser (construire). Le CLR garantit que toutes les références d’objet sont préinitialisées en null, et tous les types scalaires primitifs sont initialisés à 0, 0,0, false, etc. (Par conséquent, il est inutile de le faire de manière redondante dans vos constructeurs définis par l’utilisateur. N’hésitez pas, bien sûr. Mais sachez que le compilateur JIT n’optimise pas nécessairement vos magasins redondants.)
Outre la suppression des champs d’instance, le CLR initialise (types de 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 d’objet, qui précède le pointeur de la table de méthode. Les tableaux obtiennent également un champ Length et les tableaux d’objets obtiennent des champs Length et 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 généré par l’utilisateur ou 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 pourrait ê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 inline (en rienness) appelle des constructeurs de type de base vides.
En faisant référence à la première colonne du tableau 4, observez que nous pouvons créer et initialiser un struct D
avec quatre champs int dans environ 8 int-add-times. Désassemble 6 est le code généré à partir de trois boucles de minutage différentes, créant des A, des C et E. (Dans chaque boucle, nous modifions chaque nouvelle instance, ce qui empêche le compilateur JIT d’optimiser tout.)
Table 4 valeur et référence des heures de création d’objet de type de référence (ns)
Avg | Min | Primitif | Avg | Min | Primitif | Avg | Min | Primitif |
---|---|---|---|---|---|---|---|---|
2.6 | 2.6 | new valtype L1 | 22.0 | 20.3 | nouveau reftype L1 | 22.9 | 20.7 | nouveau rt ctor L1 |
4.6 | 4.6 | nouveau valtype L2 | 26.1 | 23.9 | nouveau reftype L2 | 27.8 | 25.4 | nouveau rt ctor L2 |
6.4 | 6.4 | new valtype L3 | 30.2 | 27.5 | nouveau reftype L3 | 32.7 | 29.9 | nouveau rt ctor L3 |
8.0 | 8.0 | new valtype L4 | 34.1 | 30.8 | nouveau reftype L4 | 37.7 | 34.1 | nouveau rt ctor L4 |
23.0 | 22.9 | new valtype L5 | 39.1 | 34.4 | nouveau reftype L5 | 43.2 | 39.1 | nouveau rt ctor L5 |
22.3 | 20.3 | new rt empty ctor L1 | 28.6 | 26.7 | new rt no-inl L1 | |||
26.5 | 23.9 | new rt empty ctor L2 | 38.9 | 36.5 | new rt no-inl L2 | |||
38.1 | 34.7 | new rt empty ctor L3 | 50.6 | 47.7 | new rt no-inl L3 | |||
34.7 | 30.7 | new rt empty ctor L4 | 61.8 | 58.2 | new rt no-inl L4 | |||
38.5 | 34.3 | new rt empty ctor L5 | 72.6 | 68.5 | new rt no-inl L5 |
Désassemblement 6 Construction d’objet 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 prochaines minutages (nouveau reftype L1, ... new reftype L5) sont pour cinq niveaux d’héritage de types de référence A
, ..., 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 heures de type référence aux heures de type valeur, nous voyons que l’allocation amortie et le coût de libération de chaque instance est d’environ 20 ns (20X int add time) 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 la moitié du temps de création de l’objet. Voir Désassemble 7.
Désassemblement 7 Construction d’objets de type référence
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 variantes sur ce scénario de construction de classe hérité.
New rt empty ctor L1, ..., new rt empty ctor L5 : Each type
A
, ...,E
a un constructeur vide défini par l’utilisateur. Ils sont tous inclus et le code généré est le même que celui ci-dessus.New rt ctor L1, ..., new rt ctor L5 : Each type
A
, ...,E
a un constructeur défini par l’utilisateur qui définit sa variable d’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 classes de base imbriquées appelle le site new
. (Désassemblage 8).
Désassembler 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
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 inline. Ce scénario simule le coût de la création d’objets complexes avec des hiérarchies d’héritage approfondies 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 dernières 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 appelé Profileur d’allocation, utilise les API de profilage 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’appel hiérarchique et vos modèles d’allocation de mémoire.
CLR Profiler vaut la peine d’apprendre, car pour de nombreuses applications de code managé « défiées aux performances », 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 applications rapides et frugals.
Le profileur CLR peut également révéler quelles méthodes allouent plus de stockage que prévu et peuvent découvrir des cas où vous conservez par inadvertance des références à des graphiques d’objets inutiles qui pourraient autrement ê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 sont sûrs de se rétablir ultérieurement. Il est tragique lorsqu’un cache maintient les graphiques d’objets vivants au-delà de leur vie utile. Au lieu de cela, veillez à annuler les références aux objets dont vous n’avez plus besoin.)
La figure 1 est une vue chronologique du tas pendant l’exécution du pilote de test de minutage. Le modèle de 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’objets (génération 0), et le garbage collector s’exécute brièvement pour le recycler et promouvoir tous les objets en direct vers la génération 1. Il est remarquable que même dans cet environnement de profilage invasif (lent), 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 place à une autre instance de E
, le garbage collector effectue un garbage collection de génération 1, qui collecte et compacte le tas gen 1, et ainsi le sawtooth continue, à partir d’une adresse de départ inférieure.
Figure1 Vue de ligne de temps du profileur CLR
Notez que plus l’objet est grand (E plus grand que D plus grand que C), plus le tas gen 0 se remplit et plus le cycle GC est fréquent.
Casts et vérifications de type d’instance
La base du lit de sécurité, sécurisée, vérifiables code managé est la sécurité de type. S’il est possible de convertir un objet en un type qu’il n’est pas, il serait simple de compromettre l’intégrité du CLR et donc de l’avoir à la merci du code non approuvé.
Table 5 Cast et isinst Times (ns)
Avg | Min | Primitif | Avg | Min | Primitif |
---|---|---|---|---|---|
0.4 | 0.4 | cast up 1 | 0.8 | 0.8 | isinst up 1 |
0.3 | 0.3 | cast vers le bas 0 | 0.8 | 0.8 | isinst down 0 |
8.9 | 8.8 | cast vers le bas 1 | 6.3 | 6.3 | isinst down 1 |
9.8 | 9.7 | cast (jusqu’à 2) vers le bas 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 montre la surcharge de ces vérifications de type obligatoires. Un cast d’un type dérivé vers un type de base est toujours sûr et gratuit ; alors qu’un cast d’un type de base vers un type dérivé doit être vérifié par type.
Un cast (vérifié) convertit la référence d’objet en type cible ou lève InvalidCastException
.
En revanche, l’instruction CIL isinst
est utilisée pour implémenter le mot clé C# as
:
bac = ac as B;
Si ac
n’est pas B
ou dérivé de B
, le résultat est null
, et non une exception.
La liste 2 illustre l’une des boucles de minutage de cast, et Désassemble 9 affiche le code généré pour un cast vers un type dérivé. Pour effectuer le cast, le compilateur émet un appel direct à une routine d’assistance.
Liste 2 boucles 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ésassembler 9 de conversion 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_ récupère la propriété ; la méthode set_ met à jour la propriété avec une nouvelle valeur.
À part cela, les propriétés se comportent et les coûts, comme les méthodes d’instance régulières et les méthodes virtuelles. Si vous utilisez une propriété pour récupérer ou stocker simplement un champ d’instance, il est généralement inline, comme avec n’importe quelle petite méthode.
Le tableau 6 indique le temps nécessaire à l’extraction (et à l’ajout) et au stockage, d’un ensemble de champs et de propriétés d’instance entiers. 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.
Table 6 Field and Property Times (ns)
Avg | Min | Primitif |
---|---|---|
1.0 | 1.0 | obtenir le champ |
1.2 | 1.2 | get prop |
1.2 | 1.2 | définir le champ |
1.2 | 1.2 | set prop |
6.4 | 6.3 | get virtual prop |
6.4 | 6.3 | définir une propriété virtuelle |
Barrières d’écriture
Le récupérateur de mémoire CLR tire parti de l'« hypothèse générationnelle »,la plupart des nouveaux objets meurent de jeunes— pour réduire la surcharge du regroupement.
Le tas est partitionné logiquement en générations. Les objets les plus récents vivent en 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, le cas échéant, gen 0 sont accessibles à partir du jeu racine GC, qui inclut des références d’objets dans les registres d’ordinateurs, sur la pile, des références d’objets de champ statique de classe, etc. Les objets transitivement accessibles sont « actifs » et promus (copiés) vers la génération 1.
Étant donné que la taille totale du tas peut être de centaines de Mo, alors que la taille de tas de génération 0 ne peut être que de 256 Ko, limiter l’étendue du suivi du graphique objet du GC au tas gen 0 est une optimisation essentielle pour atteindre les temps de pause de collection très bref 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 gen2. Étant donné que nous n’scannons pas les objets gen 1 ou gen2 pendant une collection gen 0, si c’est la seule référence à l’objet gen 0 donné, cet objet peut être récupéré par GC de manière erronée. On ne peut pas laisser ça arriver !
Au lieu de cela, tous les magasins de 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 plus anciens. Ces anciens champs de référence d’objet sont ajoutés à l’ensemble racine GC des gc(s) suivants.
La surcharge des barrières d’écriture par objet-reference-field-store 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 pour payer l’allocation d’objets super rapide et GC, et les nombreux avantages de productivité de la gestion automatique de la mémoire.
Tableau 7 temps d’accès en écriture
Avg | Min | Primitif |
---|---|---|
6.4 | 6.4 | barrière d’écriture |
Les barrières d’écriture peuvent être coûteuses dans des boucles internes serrées. Mais dans les années à venir, nous pouvons nous attendre à des techniques de compilation avancées qui réduisent le nombre de barrières d’écriture prises et le coût amorti total.
Vous pensez peut-être que les barrières d’écriture sont nécessaires uniquement 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, stocke 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 valeur 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 de matrice hors limites et les altérations de tas, et pour protéger l’intégrité du CLR lui-même, les chargements d’éléments de tableau et les magasins sont des limites vérifiées, ce qui garantit que l’index est dans l’intervalle [0,array. Longueur-1] inclusive ou levée IndexOutOfRangeException
.
Nos tests mesurent le temps de chargement ou de stockage d’éléments d’un tableau int[]
et d’un tableau A[]
. (Tableau 8).
tableau 8
Avg | Min | Primitif |
---|---|---|
1.9 | 1.9 | load int array elem |
1.9 | 1.9 | stocker int array elem |
2.5 | 2.5 | load obj array elem |
16.0 | 16.0 | store obj array elem |
La vérification des limites nécessite de comparer l’index de tableau au tableau implicite. Champ Longueur. Comme le montre Le désassemblement 10, dans seulement deux instructions, nous vérifions que l’index n’est ni inférieur à 0 ni supérieur ou égal au tableau. Longueur : si c’est le cas, nous créons une branche vers une séquence hors ligne qui lève l’exception. Il en va 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. (Ltableau oad obj elem temps est (de façon insignifiante) plus lent en raison d’une légère différence dans sa boucle interne.)
élément Désassembler 10 Load int array
; 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 qualité du code, le compilateur JIT élimine souvent les vérifications de limites redondantes.
Rappelant les sections précédentes, nous pouvons nous attendre à ce que magasins d’éléments de tableau d’objets soit 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 :
- vérifier que l’index de tableau est dans les limites ;
- l’objet check est une instance du type d’élément de tableau ;
- effectuez une barrière d’écriture (notant toute référence d’objet intergénérationnelle du tableau à l’objet).
Cette séquence de code est plutôt longue. Au lieu de l’émettre sur chaque site de magasin d’objets, le compilateur émet un appel à une fonction d’assistance partagée, comme indiqué dans Désassemble 11. Cet appel, ainsi que ces trois actions comptent pour le temps supplémentaire nécessaire dans ce cas.
élément de tableau d’objets Assemble 11 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
Boxing et unboxing
Un partenariat entre les compilateurs .NET et le CLR permet aux types valeur, 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’objet. Cette affordance ( ce sucre syntaxique) permet aux types valeur d’être passés à des méthodes en tant qu’objets, stockés 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 d’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 boîte d’un int, et plus tard pour le garbage collect, est comparable au temps nécessaire pour instancier une petite classe avec un champ int.
Table 9 Box and Unbox int Times (ns)
Avg | Min | Primitif |
---|---|---|
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 boxed, vous devez effectuer un cast explicite en int. Cette opération est compilée dans une comparaison du type de l’objet (représentée 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 hors de l’objet. Sinon, une exception est levée. Voir Désassemble 12.
Désassembleur 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ée. L’adresse d’une fonction membre non virtuelle peut être une adresse de code simple, mais l’adresse d’une fonction membre virtuelle doit incarner un appel de fonction membre virtuel particulier, la déréférencement d’une telle 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, dans l’équipe de développement du compilateur Visual C++, nous avons l’habitude de nous demander, quel genre de bêteie est l’expression nue pa->*pmf
(sans opérateur d’appel de fonction) ? Nous l’avons appelé un pointeur lié à la fonction membre, mais appel de fonction membre latent est tout aussi apt.
Retour à la terre du 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, simplement la méthode statique à appeler.
(Comme l’indique 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. Une instance de délégué encapsule une méthode statique ou une méthode d’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 générer 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 installation pour l’appel de méthode asynchrone. Après avoir défini un type 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ée, le runtime met l’appel en file d’attente 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 peu coûteuses. Comparaison du tableau 10 et du tableau 3, notez que l’appel de délégué est ** environ huit fois plus lent qu’un appel de méthode. Attendez-vous à améliorer au fil du temps.
table 10 heure d’appel de délégué (ns)
Avg | Min | Primitif |
---|---|---|
41.1 | 40.9 | appel délégué |
Des erreurs de cache, des erreurs de page et de l’architecture de l’ordinateur
De retour dans les « bons vieux jours », vers 1983, les processeurs étaient lents (~.5 millions d’instructions/s) et relativement parlant, la RAM était assez rapide mais faible (environ 300 ns temps d’accès sur 256 Ko de DRAM), et les disques étaient lents et volumineux (environ 25 ms temps d’accès sur 10 Mo de disques). Les microprocesseurs PC étaient des CISC scalaires, la plupart des virgules flottantes étaient dans les logiciels, et il n’y avait pas de caches.
Après vingt ans de loi de Moore, Environ 2003, les processeurs sont
Dans les bons jours, vous pouviez, et parfois, compter les octets de code que vous avez écrits, et compter le nombre de cycles nécessaires à l’exécution du code. Une charge ou un magasin a pris environ 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 hors ordre (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.
À présent, nos PC les plus rapides peuvent émettre jusqu’à environ 9 000 opérations par microseconde, mais dans ce même microseconde, chargez ou stockez uniquement sur DRAM ~10 lignes de cache. Dans les cercles d’architecture informatique, il s’agit de frapper la mémoiremur. Les caches masquent la latence de la mémoire, mais uniquement à un point. Si le code ou les données ne tiennent pas dans le cache et/ou présente une mauvaise localité de référence, notre jet supersonique de 9 000 opérations par microseconde dégénère à un tricycle de 10 charge par microseconde.
Et (ne laissez pas cela se produire) si l’ensemble de travail d’un programme dépasse la RAM physique disponible et que le programme commence à prendre des erreurs de page dur, puis dans chaque service d’erreur de page de 10 000 microsecondes (accès au disque), nous ne pouvons pas amener l’utilisateur à 90 millions d’opérations plus près de leur réponse. C’est si horrible que je crois que vous allez de ce jour prendre soin de mesurer votre ensemble de travail (vadump) et d’utiliser des outils comme CLR Profiler pour éliminer les allocations inutiles et les rétentions de graphiques d’objets par inadvertance.
Mais que doit-il faire pour connaître le coût des primitives de code managé ?Tout*.*
Rappelant le tableau 1, la liste globale des temps primitifs de code managé, mesurée 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 accès DRAM unique. Une seule charge qui manque tous les niveaux de cache à puce peut prendre plus de temps pour le service que presque toute opération de code managé unique.
Par conséquent, si vous êtes passionné par la vitesse de votre code, il est impératif que vous considériez et mesurez la hiérarchie cache/mémoire lorsque vous concevez et implémentez vos algorithmes et structures de données.
Temps d’une démonstration simple : est-il plus rapide de additionner un tableau d’ints ou de somme d’une liste liée équivalente d’ints ? Qui, 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 une quatrième de la liste liée. (Chaque nœud de liste liée comporte deux mots de surcharge d’objet et deux mots de champs (lien suivant et élément int).) Cela va nuire à l’utilisation du cache. Noter un pour l’approche de tableau.
Toutefois, la traversée de tableau peut entraîner une vérification des limites de tableau par élément. Vous venez de constater que la vérification des limites prend un peu de temps. Peut-être que cela donne des conseils sur les échelles en faveur de la liste liée ?
Désassemblement 13 Tableau d’int somme par rapport à la liste liée somme
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 faisant référence au désassemblement 13, j’ai empilé le jeu en faveur de la traversée de liste liée, en la déroutant quatre fois, même en supprimant la vérification de fin de liste de pointeur Null habituelle. 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 de 11/4 = 2,75 instructions. Maintenant, que supposez-vous est plus rapide ?
Conditions de test : commencez par créer un tableau d’un million d’ints et une liste liée simple et traditionnelle d’un million d’ints (1 nœud de liste M). Ensuite, le temps nécessaire pour ajouter les 1 000 premiers, 10 000, 10 000, 100 000 et 1 000 000 éléments. Répétez plusieurs fois chaque boucle pour mesurer le comportement de cache le plus flatteur pour chaque cas.
Qu’est-ce qui est plus rapide ? Une fois que vous avez deviné, reportez-vous aux réponses : les huit dernières entrées du tableau 1.
Intéressant! Les temps sont nettement plus lents, car les données référencées augmentent de plus en plus grande 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 est-ce si ? Tout d’abord, moins d’éléments de liste liés correspondent à un niveau donné de cache. Tous ces en-têtes d’objet et liens gaspillent de l’espace. Deuxièmement, notre processeur de flux de données obsolète 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 en cache, le processeur ne peut pas commencer à récupérer le lien suivant vers le nœud après cela.
Dans le cas des 100 000 éléments, le processeur dépense (en moyenne) environ (22-3,5)/22 = 84% de son temps en attendant que la ligne de cache d’un nœud de liste soit lue à partir de DRAM. Cela semble mauvais, mais les choses pourraient être beaucoup pire. É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 garbage collector conserve l’ordre d’allocation, même s’il compacte les objets morts hors du tas, il est probable, après avoir récupéré un nœud sur une ligne de cache, que les plusieurs nœuds suivants sont désormais également dans le cache. Si les nœuds étaient plus volumineux ou si les nœuds de liste étaient dans un ordre d’adresse aléatoire, chaque nœud visité peut être un manque complet de cache. 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 le double à 146 ns/élément, probablement la latence moyenne de la mémoire DRAM sur la machine de test.
Qu’est-ce que la leçon à prendre ici ? Évitez les listes liées de 100 000 nœuds ? Aucun. La leçon est que les effets du cache peuvent dominer n’importe quelle considération de faible efficacité du code managé par rapport au code natif. Si vous écrivez du code managé critique pour les performances, en particulier le 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.
De cette façon, la tendance est que le mur de mémoire, le ratio du temps d’accès DRAM divisé par le temps d’opération du processeur, continuera à s’aggraver au fil du temps.
Voici quelques règles de « conception consciente du cache » de pouce :
- Expérimentez et mesurez vos scénarios, car il est difficile de prédire les effets de deuxième ordre et parce que les règles de pouce ne valent pas la peine d’imprimer le papier sur lequel ils sont imprimés.
- Certaines structures de données, illustrées par des tableaux, utilisent dedjacency implicite pour représenter une relation entre les données. D’autres, illustrées par des listes liées, utilisent pointeurs explicites (références) pour représenter la relation. L’adjacency implicite est généralement préférable : la « impliciteté » permet d’économiser de l’espace par rapport aux pointeurs ; et l’adjacency fournit une localité stable de référence et peut permettre au processeur de commencer plus de travail avant de descendre le pointeur suivant.
- Certains modèles d’utilisation favorisent 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 de nouveau lorsque les accès au disque coûtent seulement 50 000 instructions sur l’UC, doivent être recyclés maintenant que les accès DRAM peuvent prendre des milliers d’opérations de processeur.
- Étant donné que le garbage collector clR mark-and-compact conserve l’ordre relatif des objets, objets alloués ensemble dans le temps (et sur le même thread) ont tendance à rester ensemble dans l’espace. Vous pouvez utiliser ce phénomène pour colocaliser de manière réfléchie les données cliquish sur des lignes de cache communes.
- Vous souhaiterez peut-être partitionner vos données en parties chaudes qui sont fréquemment parcourues et doivent s’adapter dans le cache, et les parties froides rarement utilisées et peuvent être « mises en cache ».
Expériences de tempsIt-Yourself
Pour les mesures de minutage dans ce document, j’ai utilisé le compteur de performances win32 haute résolution QueryPerformanceCounter
(et QueryPerformanceFrequency
).
Elles sont facilement appelées 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, soustraire les nombres, multiplier par 1,0e9, diviser par fréquence, diviser par nombre d’itérations, et c’est votre temps approximatif 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é de l’accès au code. Considérez-le comme un exercice pour le lecteur.
De cette façon, j’ai produit les désassembles dans cet article à l’aide de la fenêtre désassemblement dans VS.NET 2003. Mais il y a un truc pour ça. 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 est exécuté en « mode débogage » dans lequel les optimisations telles que l’incorporation sont désactivées. La seule façon dont j’ai trouvé pour obtenir un aperçu du code natif optimisé émis par le compilateur JIT était de lancer mon application de test en dehors de le débogueur, puis de l’attacher à celui-ci à l’aide de Debug.Process.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 brefs paragraphes, puis.
Considérations de bas niveau (plusieurs étant C# (typeAttributes.SequentialLayout par défaut) et x86 spécifiques) :
- La taille d’un type valeur est 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 des attributs
[StructLayout(LayoutKind.Explicit)]
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.
- Dans C#, les déclarations d’énumération peuvent spécifier un type de base intégral arbitraire (sauf char) ; il est donc possible de définir des énumérations 8 bits, 16 bits, 32 bits et 64 bits.
- Comme en C/C++, vous pouvez souvent raser quelques dizaines d’espace hors 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 le profileur CLR.
- Les objets volumineux (plusieurs dizaines de Ko ou plus) sont gérés dans un tas d’objets volumineux distinct, afin d’empêcher la copie coûteuse.
- Les objets finalisables prennent une génération GC supplémentaire pour récupérer : utilisez-les avec parcimonie et envisagez d’utiliser le modèle De suppression.
Considérations relatives à l’image globale :
- Chaque AppDomain entraîne actuellement une surcharge d’espace importante. De nombreuses structures runtime et Framework ne sont pas partagées entre AppDomains.
- Dans un processus, le code jitted 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 relative aux
CorBindToRuntimeEx
et à l’indicateur deSTARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN
. - En tout cas, le code jitted 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 demander quels coûts de réflexion, vous ne pouvez pas vous en permettre ». Si vous avez lu jusqu’à présent, vous savez combien il est important de demander ce qui coûte, et de mesurer ces coûts.
La réflexion est utile et puissante, mais comparée au code natif jitted, il n’est ni rapide ni petit. Tu as été averti. Mesurez-le pour vous-même.
Conclusion
Maintenant, vous savez (plus ou moins) quel est le coût du code managé au niveau le plus bas. Vous disposez maintenant de la compréhension de base nécessaire pour faire des compromis d’implémentation plus intelligents et écrire du code managé plus rapide.
Nous avons vu que le code managé jitted peut être comme « pédale vers le métal » comme code natif. Votre défi est de coder sagement et de choisir sagement 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 les paramètres où il s’agit de la fonctionnalité la plus importante d’un produit. L’optimisation prématurée est la racine de tous les maux. Mais c’est une inattention à l’efficacité. Vous êtes un professionnel, un artiste, un artisan. Alors assurez-vous que vous connaissez le coût des choses. Si vous ne connaissez pas ou même si vous pensez que vous le faites , 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 est encore plus rapide que le code natif. Attendez-vous à ce que les choses s’améliorent et s’améliorent. Restez à l’écoute.
N’oubliez pas votre promesse.
Ressources
- David Stutz et al, CLI de source partagée Essentials. O’Reilly et Assoc., 2003. ISBN 059600351X.
- Jan Gray, C++ : Sous le capot.
- Gregor Noriskin, Écriture d’applications managées High-Performance : A Primer, MSDN.
- Rico Mariani, Garbage Collector Basics and Performance Hints, MSDN.
- Emmanuel Schanzer, conseils et astuces sur les performances dans les applications .NET, MSDN.
- Emmanuel Schanzer, considérations relatives aux performances pour les technologies Run-Time dans le .NET Framework, MSDN.
- vadump (Outils sdk de plateforme), MSDN.
- .NET Show, [Managed] Code Optimization, 10 septembre 2002, MSDN.