Dans les applications à performances critiques, chaque microseconde compte et le temps perdu peut rapidement s'additionner et devenir un goulot d'étranglement. Nous aborderons quelques conseils généraux sur les performances de programmation, ainsi que les meilleures pratiques et optimisations spécifiques à .NET.
Comment chronométrer votre code
Le moyen le plus simple de mesurer la durée d'une fonction consiste à utiliser un Chronomètre. Cette classe peut prendre des mesures très précises du code lors du débogage, et peut même être utilisée par programme pour des choses comme la définition de délais d'expiration.
Chronomètre montre = System.Diagnostics.Stopwatch.StartNew (); // le code que vous souhaitez mesurer watch.Stop (); double elapsedMs = watch.Elapsed.TotalMilliseconds;
Bien que ce soit assez précis, cela peut conduire à des résultats inattendus – il ne prend pas en compte le «bruit de fond», comme .NET exécutant la compilation JIT d'une fonction, ce qui peut souvent conduire à la toute première exécution plus longue. Vous devriez prendre une moyenne sur plusieurs courses pour obtenir des résultats plus précis.
Pour une meilleure expérience de débogage, vous devez utiliser un profileur de performances. Visual Studio dispose d'excellents outils de débogage intégrés qui peuvent même afficher des graphiques indiquant les fonctions qui prennent le plus de temps.
Vous pouvez également utiliser un profileur de performances pour déboguer l'utilisation de la mémoire. Cliquez simplement sur «Prendre un instantané» et vous pouvez afficher tous les objets actuellement alloués.
Vérifiez vos boucles
Celui-ci est un peu évident, mais il faut le mentionner. Si vous écrivez une fonction qui n'est appelée que quelques fois par minute, peu importe si cela prend 5 microsecondes ou 5 millisecondes. Ce n’est probablement pas un goulot d’étranglement. (Bien que cela puisse certainement conduire à un mauvais code dans des endroits sans importance qui ne soit pas vérifié, et vous devriez toujours écrire un bon code en principe.)
Cependant, si vous faites quelque chose plusieurs fois, ou faites quelque chose à chaque tick ou à chaque demande, les normes sont immédiatement différentes. Même de petites améliorations sont beaucoup plus importantes, car plus de 10 000 cycles de boucle peuvent s'additionner rapidement.
Un autre scénario similaire concerne les fonctions avec des tailles de paramètres illimitées. Par exemple, vous pouvez avoir une fonction qui fonctionne bien avec une petite chaîne ou une petite liste, mais si vous lui passez une liste avec 10 000 éléments, des algorithmes mal optimisés peuvent se révéler.
Gardez à l'esprit Big O Notation
En général, les performances d’un ordinateur sont généralement limitées par l’un des deux éléments suivants: la vitesse du processeur et les performances de la mémoire. Si vous pouvez réduire le nombre de cycles de processeur requis ou la quantité de mémoire utilisée par quelque chose, vous pouvez généralement l'accélérer.
La notation Big O est utilisée pour catégoriser la vitesse et la complexité des algorithmes. Vous pouvez le considérer comme un graphique, avec la taille de l'entrée (comme le nombre d'éléments de liste) sur l'axe x et le temps d'exécution sur l'axe y.
Le monde réel est évidemment très compliqué, donc plutôt que d'essayer de décrire le temps d'exécution exact, la notation Big O est censée représenter le forme de la courbe elle-même. Vous pouvez voir rapidement où quelque chose comme O (N ^ 2), ou temps polynomial, serait un problème; avec 100 éléments de liste, cela pourrait fonctionner correctement, mais avec 10 000 éléments de liste, il pourrait s'arrêter.
La notation Big O n'est pas seulement pour le temps d'exécution, elle peut également être utilisée pour décrire d'autres utilisations de ressources telles que l'espace disque et l'empreinte mémoire.
Dans .NET, l'une des plus grandes améliorations d'algorithme que vous pouvez faire est de remplacer les recherches de liste par des structures de données basées sur le hachage comme dictionnaire
et HashSet
, plutôt que de rechercher dans de grandes listes par itération.
Par exemple, ce code boucle ici sur chaque La personne
objet dans une grande liste, en vérifiant si certains sont nommés Steve. Pour les petites listes, ce n'est pas un gros problème, mais cette requête se résout en SUR) temps, ou temps linéaire, et augmentera si vous devez le faire souvent.
Si vous recherchez généralement en fonction du prénom, vous pouvez à la place utiliser un dictionnaire, qui stocke une liste unique d’objets basée sur le hachage de la clé donnée. Dans ce cas, il stocke le hachage du prénom de chaque personne, bien que vous deviez le générer manuellement et le synchroniser avec toutes les autres recherches que vous pourriez avoir.
La recherche d'une valeur de dictionnaire est un O (1), ou temps fixe, opération. Quelle que soit la taille de la liste, il en coûte le même prix pour faire le hachage et la recherche. Cela s'applique également aux recherches directes de tableaux, comme tableau (i)
, qui est juste un accès direct à la mémoire.
Un autre gros porc de performance est .Contient ()
, une fonction qui vérifie si une liste contient quelque chose. Sur un liste
, cela se fait en temps linéaire. Si la liste que vous vérifiez est volumineuse ou si vous le faites plusieurs fois, vous pouvez utiliser un HashSet. Un HashSet est un ensemble unique d'éléments qui stocke un hachage à côté de chaque entrée, essentiellement comme un dictionnaire sans clé prédéfinie. Faire .Contient ()
sur un hashset se fait en temps linéaire.
Enfin, les requêtes LINQ utilisent en fait une exécution différée. Par exemple, .Où()
vérifie chaque élément pour voir s'il correspond à une requête. Sauf que cela ne renvoie pas de nouvelle liste – il renvoie un IEnumerable, qui reporte l'exécution jusqu'à ce qu'il soit itéré ou transformé manuellement en une liste avec .Lister()
.
Cela signifie que si vous effectuez deux requêtes, vous n’effectuez pas deux itérations, vous effectuez simplement deux vérifications en une itération, ce qui supprime toute la surcharge liée aux boucles inutiles.
EN RELATION: Comment fonctionnent les énumérateurs et les énumérables en C #?
Réduisez vos déchets, évitez les allocations inutiles
C # et d'autres langages .NET utilisent un garbage collector, qui analyse périodiquement la mémoire et nettoie les objets qui n'ont plus de références. C'est fantastique pour les développeurs, car cela leur permet d'écrire du code plus simple beaucoup plus rapidement.
Mais, comme il n’existe pas de repas gratuit, cela a un coût: les performances. Lorsque le GC s'exécute, il doit interrompre l'exécution du programme, ce qui affecte les performances. Cependant, ce n'est pas parce que C # est récupéré de la mémoire que vous devez faire des déchets inutiles. Tout ce que vous pouvez faire pour soulager la pression sur le CPG et déclencher le CPG moins souvent entraînera de meilleures performances.
Mais qu'est-ce que les ordures? Dans .NET, il existe deux types de types différents: les types valeur et les types référence. Les types de valeur ont des tailles fixes, comme int
, booléen
, flotte
et d'autres structures de taille fixe. Ils sont stockés sur le empiler, qui est une structure de données en mémoire très rapide.
La pile n’est pas rapide parce qu’il s’agit d’une mémoire spéciale, elle est rapide parce qu’il s’agit d’une structure de données LIFO (Last-In, First-Out) entièrement gérée. Les variables que vous allouez s'empilent essentiellement les unes sur les autres dans un compartiment. Lorsque les variables sortent du champ d'application, comme lorsque la fonction existe, votre ordinateur atteint le seau et supprime l'élément supérieur, un par un jusqu'à ce que la pile soit à nouveau propre. Cela le rend très rapide. (Bien que les allocations de pile soient bon marché, elles ne sont pas entièrement gratuites.)
Les autres types de valeurs sont stockés par référence et sont utilisés pour tout type qui n’a pas de taille fixe comme les listes et chaîne
. La variable qui contient la référence est stockée sur la pile, mais la mémoire sous-jacente est stockée sur le tas.
Comparé à la pile, le tas est une fosse d'allocations mosh non gérée. Non seulement il est beaucoup plus difficile à nettoyer, nécessitant l’utilisation d’un ramasse-miettes, mais il est également beaucoup plus difficile d’allouer des objets. Certes, c'est toujours assez rapide, mais comparé aux allocations de pile, c'est beaucoup plus lent.
Chaque fois que vous initialisez quelque chose avec le Nouveau
mot-clé, gardez à l'esprit que vous créez de la mémoire qui doit être nettoyée plus tard. Par exemple, le code suivant n'utilise qu'une seule variable, mais comme vous allouez deux nouveaux objets, le premier est jeté.
Fruit pomme = nouveau fruit (); pomme = nouveau fruit ();
Lorsque vous réinitialisez cette variable, vous ne faites que la définir sur une adresse mémoire différente. Le garbage collector réalisera que vous ne vous souciez plus de la première adresse, mais il peut être facile de ne pas s'en soucier lorsque vous n'êtes pas celui qui nettoie.
Mettre en commun des objets coûteux lorsque cela est possible
Le regroupement d'objets est fondamentalement comme le recyclage au lieu de simplement jeter des choses, et cela peut conduire à de grandes accélérations lors de nombreuses allocations dans une boucle.
Par exemple, le code suivant s'exécute 10 000 fois et laisse 10 000 listes de déchets à la fin.
Le point ici est que cette fonction ne nécessite qu'une seule liste de mémoire, mais elle utilise en fait 10 000 fois plus de mémoire que ce dont elle a besoin.
Avec un pool d'objets, plutôt que d'attribuer directement un nouvel objet, vous en demandez un au pool. La piscine peut créer un nouvel objet s’il n’en a pas de disponible. Ensuite, lorsque vous en avez terminé, vous relâchez cet objet dans la piscine.
Plutôt que de jeter l'objet à la poubelle, le pool d'objets le garde alloué mais l'efface de toutes les données. La prochaine fois que vous demandez un nouvel objet, il renvoie un ancien objet désormais vide. Pour les objets volumineux tels que les listes, cela est beaucoup plus facile pour la mémoire sous-jacente.
Une autre mise en œuvre plus générale de ce concept consiste simplement à effacer de grandes listes plutôt qu'à en attribuer de nouvelles. Cela produit le même effet d'utilisation optimale de la mémoire, sans utiliser explicitement un pool d'objets.
Si vous souhaitez utiliser des pools d'objets, Microsoft propose une implémentation en Microsoft.Extensions.ObjectPool
. Si vous souhaitez l'implémenter vous-même, vous pouvez ouvrir la source à découvrez comment cela fonctionne dans DefaultObjectPool
. En règle générale, vous disposez d'un pool d'objets différent pour chaque type, avec un nombre maximal d'objets à conserver dans le pool.
Conseils de sérialisation JSON
La sérialisation et la désérialisation JSON constituent souvent un goulot d'étranglement majeur en matière de performances. Après tout, cela fonctionne avec des chaînes gigantesques et d'énormes morceaux de mémoire. Heureusement, il existe quelques astuces pour accélérer les choses.
Désérialiser des flux
Si vous travaillez avec de gros morceaux de JSON, en particulier ceux supérieurs à 85 Ko, vous souhaiterez désérialiser uniquement à partir d'un flux mémoire. En effet, pour travailler avec des chaînes de plus de 85 Ko, elles doivent être stockées sur le ltas d'objets arge, ce qui n’est pas bon pour les performances. Lorsque vous désérialisez à la place d'un flux mémoire, cela n'a pas d'importance, car seul un petit morceau est lu à la fois.
Client HttpClient = nouveau HttpClient (); en utilisant (Stream s = client.GetStreamAsync ("http://www.test.com/large.json"). Résultat) en utilisant (StreamReader sr = nouveau (s) StreamReader (s)) en utilisant (JsonReader reader = new JsonTextReader (sr)) { Sérialiseur JsonSerializer = nouveau JsonSerializer (); Personne p = sérialiseur.(lecteur); }
Sérialisation manuelle
En plus de travailler avec beaucoup de mémoire, la sérialisation JSON est lente pour une autre raison: la réflexion. Pour désérialiser un objet générique dans un type donné, la plupart des convertisseurs JSON doivent examiner la structure du type qui lui est passé, ce qui est une opération lente.
Cependant, dans les scénarios critiques pour les performances, vous pouvez l'accélérer considérablement en sérialisant manuellement chaque champ, en contournant la surcharge de la sérialisation basée sur la réflexion. Dans certains cas, cela peut être jusqu'à deux fois plus rapide que la sérialisation standard.
chaîne statique publique ToJson (cette personne p) { StringWriter sw = nouveau StringWriter (); JsonTextWriter writer = new JsonTextWriter (sw); writer.WriteStartObject (); writer.WritePropertyName ("nom"); writer.WriteValue (p.Name); writer.WritePropertyName ("aime"); writer.WriteStartArray (); foreach (chaîne comme dans p.Likes) { writer.WriteValue (comme); } writer.WriteEndArray (); writer.WriteEndObject (); return sw.ToString (); }
Envisagez d'utiliser Jil
Jil est un sérialiseur / désérialiseur .NET JSON de StackExchange, avec un certain nombre de «trucs d'optimisation un peu fous». Il est plus de deux fois plus rapide pour la sérialisation que NewtonSoft et presque deux fois plus rapide pour la désérialisation (mais avec le compromis d'utiliser plus de mémoire). Si vous êtes intéressé, vous pouvez tout savoir sur leurs optimisations de performances sur leur page GitHub.
Utiliser StringBuilder pour les chaînes mutables
Les chaînes sont essentiellement des listes déguisées, et il est assez facile d’oublier ce fait lorsque vous travaillez avec elles. Le fait qu’ils soient immuable ainsi que. Cela peut conduire à des allocations de mémoire beaucoup plus que nécessaire.
Par exemple, prenez le code suivant qui fonctionne avec quelques chaînes:
string first = "Bonjour"; first + = "Monde";
La compréhension intuitive est que cela ajoute simplement «World» à la fin de la chaîne et change la valeur de la variable. Mais ce qu'il fait en fait, c'est créer une chaîne entièrement nouvelle, avec un morceau de mémoire séparé, et jeter la première chaîne. le première
La variable est modifiée pour pointer vers une nouvelle adresse mémoire.
Il est rapidement clair que faire cela plusieurs fois en boucle est terrible pour les performances. La solution est la StringBuilder, qui stocke à la place la chaîne dans une structure semblable à une liste. Cela vous permet de modifier directement la chaîne elle-même, ce qui économise une tonne de mémoire (et de temps d'allocation de mémoire) dans le processus.
Pour les utiliser, créez un nouveau StringBuilder ()
, que vous pouvez initialiser avec une chaîne de base et préallouer un certain nombre de caractères. Vous pouvez ensuite y ajouter d'autres chaînes ou caractères, puis lorsque vous souhaitez créer une chaîne réelle, vous pouvez appeler .ToString ()
dessus.
static void Main () { // Crée un StringBuilder qui s'attend à contenir 50 caractères. // Initialise le StringBuilder. StringBuilder sb = new StringBuilder ("Bonjour,", 50); sb.Append ("Monde"); Console.WriteLine (sb.ToString ()); }
Utiliser stackalloc le cas échéant
stackalloc
est une fonctionnalité utilisée pour allouer une liste sur la pile, ce qui est beaucoup plus rapide que les allocations de tas et ne produit pas de déchets. Auparavant, cela nécessitait l'utilisation de pointeurs dans un peu sûr
context, mais depuis C # 7.2, vous pouvez l'utiliser avec Envergure
pour le faire en toute sécurité:
Enverguredonnées = octet stackalloc (256);
Bien sûr, une grande puissance s'accompagne d'une grande responsabilité, et stackalloc
ne doit être utilisé qu'avec parcimonie dans des scénarios spécifiques. Si vous êtes intéressé, vous pouvez lisez cet article sur les choses à faire et à ne pas faire, mais la règle générale est de ne l'utiliser que pour des variables locales relativement petites, taille fixe listes qui sortent rapidement de la portée lorsque la fonction se termine. Vous ne devez jamais l'utiliser avec des longueurs d'allocation variables ou dans une boucle de longueur variable.
Utiliser des tâches et des coroutines pour de longues fonctions
Parfois, vous ne pouvez pas vous déplacer en effectuant des calculs coûteux. Cependant, vous pouvez minimiser leur effet sur l'utilisateur final. Si vous créez une application de bureau, l'attente de longues actions telles que les requêtes Web doit être effectuée sur un fil distinct, sinon vous entraînerez le blocage de l'ensemble de l'application.
Cela se fait généralement avec les tâches, qui sont mises en file d'attente sur un thread séparé une fois démarrées, et peuvent attendre
d'autres tâches, comme récupérer une requête Web ou quelque chose à partir d'une base de données. Une fois cela fait, la tâche est détruite et le résultat est accessible depuis le thread principal.
<img class = "alignnone wp-image-6268 size-full" data-pagespeed-lazy-src = "https://www.cloudsavvyit.com/thumbcache/0/0/f91922419de7a62b51268c9907e9ecd6/p/uploads/2020/08/ cdfe5ca9.png "alt =" Créer une tâche en écrivant une fonction asynchrone avec un type de retour Task
EN RELATION: Comment fonctionnent les tâches en C #? Threads asynchrones / d'arrière-plan
L'un des grands avantages de Tasks est que, comme ils sont sur un thread différent, ils peuvent être traités simultanément. Par exemple, vous pouvez commencer à effectuer la désérialisation JSON sur un thread différent, puis effectuer un autre traitement sur le thread principal, et attendre
le résultat JSON une fois que vous en avez besoin. Pour certaines applications, vous pouvez lire le processus en multithread, ce qui lui permet d’utiliser toute la puissance d’un processeur.
Une autre fonctionnalité C # utile sont les coroutines utilisant IEnumerators. Contrairement aux tâches, celles-ci sont destinées à être exécutées sur le thread principal, mais appelées une fois par image pour effectuer un certain traitement. Par exemple, dans les jeux vidéo, la fréquence d'images compte beaucoup, donc faire quelque chose comme charger une grande liste d'objets peut provoquer un bégaiement désagréable. En utilisant une coroutine, vous pouvez la configurer pour ne traiter que X éléments par image, puis passer à l'image suivante.
EN RELATION: Comment fonctionnent les énumérateurs et les énumérables en C #?
Encore une fois, aucune de ces deux techniques ne rend votre code plus rapide (sauf si vous utilisez des tâches pour le traitement simultané), mais elles peuvent rendre le code lent moins perceptible pour l'utilisateur.
Évitez la boxe et le déballage inutiles
objet
est la classe de base ultime dans .NET. Même les types de valeur, comme int
et booléen
, sont des objets. Pour cette raison, les méthodes et les classes qui acceptent objet
peut être très utile pour le polymorphisme. Mais, dans la plupart des cas, vous devriez plutôt chercher à utiliser des paramètres de type générique, pour éviter une boxe inutile.
La boxe est un concept utilisé pour transmettre des types valeur aux méthodes acceptant objets
. Chaque fois que vous encadrez un type valeur, il est copié dans le tas, ce qui entraîne une allocation de tas et une copie de la mémoire. C'est évidemment moins performant, donc à chaque fois que vous pouvez vous en tirer avec des paramètres de type générique et éviter la boxe, vous devriez le faire.
En particulier, cela comprend Liste des tableaux, un format de liste plus ancien qui utilisait beaucoup la boxe. Il a été progressivement abandonné au profit des plus performants liste
, que vous devriez certainement utiliser à la place.
Optimiser les itérations de liste 2D pour la prélecture du cache
Celui-ci est un peu étrange, mais pour les listes à deux dimensions, l'ordre des itérations est important. Par exemple, considérons un Réseau (3) (4)
, ainsi:
(1 2 3 4), (5 6 7 8), (9 1 2 3)
Vous accéderiez d'abord à chaque élément en lui donnant une valeur Y, puis en lui donnant une valeur X.
liste (y) (x)
Le problème est que .NET stocke cette liste dans l'ordre de gauche à droite, en descendant la liste. Si vous deviez d'abord itérer sur Y (c'est-à-dire de haut en bas, en vous déplaçant le long de chaque colonne), vous itéreriez sur de gros morceaux de mémoire.
Parce que les processeurs modernes utilisent beaucoup prélecture du cache (en gros, en récupérant les 64 octets contigus suivants avant qu'ils ne soient réellement demandés) pour améliorer les performances, vous souhaiterez parcourir la liste dans l'ordre dans lequel elle est stockée en mémoire. La différence peut être radicale: pour les grandes listes, une itération dans le mauvais sens peut être plus de 10 fois plus lente.