« .NET 6 est vraiment le .NET le plus rapide à ce jour », avait déclaré Alex Yakunin, Directeur technique chez ServiceTitan, une plateforme de technologie logicielle. Selon Yakunin, .NET 6 apporte jusqu'à 40 % de gain de vitesse par rapport .NET 5. Un peu plus tôt le 9 novembre, Microsoft a annoncé .NET 6, comme la version la plus rapide à ce jour avec prise en charge des puces Apple Silicon, des containers Windows isolés des processus et une amélioration des E/S de fichiers.
« Bienvenue à .NET 6. La version d'aujourd'hui est le résultat d'un peu plus d'un an d'efforts de la part de l'équipe .NET et de la communauté. .NET 6 est la première version qui prend en charge de manière native les processeurs Apple Silicon (Arm64) et a également été améliorée pour Windows Arm64 », avait alors déclaré Richard Lander, responsable de programme au sein de l'équipe .NET Core.
Suite à des tests de comparaison entre .NET 6 et .NET 5, Alex Yakunin conclut que :
- les tests de mise en cache et de boucles serrées serraient jusqu'à 30 % plus rapide avec .NET 6 ;
- l’ajout du cache de Fusion à une API web ordinaire, obtient une accélération de 15 % avec .NET 6 ;
- le test du pipeline HTTP "classique" (HttpClient frappant un contrôleur ASP.NET Core qui héberge un service EF Core de type CRUD) obtient une accélération d'environ 4,5 %.
Lorsque le développement de .NET 7 a commencé, Microsoft a expliqué à la communauté que cette nouvelle version unifiera enfin tous les composants disparates des outils de développement .NET, permettant aux développeurs de créer tous les types d'applications - bureautiques, mobiles, Web et autres - sur la même bibliothèque de classes de base (BCL), le même moteur d'exécution et les mêmes compilateurs. C'était en fait l'objectif de .NET 5 - qui succède aux offres .NET Core - lorsqu'il a fait ses débuts en novembre 2020. Mais des problèmes de développement exacerbés par la pandémie n'ont pas permis d'atteindre cet objectif.
En effet, tous les éléments prévus n'ont pas été intégrés à .NET 5 et ont été reportés jusqu'à l'arrivée de .NET 6 en novembre 2021 en tant que version LTS (Long Term Support). Mais même à ce moment-là, l'effort d'unification global de Microsoft était incomplet, car certains composants, tels que .NET Multi-platform App UI (.NET MAUI), n'ont pas respecté le calendrier. .NET MAUI a depuis atteint la disponibilité générale, et l'unification complète est désormais attendue pour novembre. Lors de la célébration des 20 ans de .NET en février dernier, Microsoft a réitéré son intention d'unifier tous les composants du framework à partir de .NET 7.
Maintenant qu'une version candidate de .NET 7 est sur le point de sortir, Microsoft estime que c'est le bon moment pour discuter des questions de performances. « Au cours de l'année écoulée, chaque fois que j'ai passé en revue un RP susceptible d'avoir un impact positif sur les performances, j'ai copié ce lien dans un journal que je tiens à jour. Il y a quelques semaines, j'ai été confronté à une liste de près de 1 000 RP ayant un impact sur les performances. », a écrit Stephen Toub. « .NET 7 est rapide. Vraiment rapide. Un millier de RP ayant un impact sur les performances ont été introduits dans les bibliothèques d'exécution et de base dans cette version, sans parler de toutes les améliorations apportées à ASP.NET Core, Windows Forms, Entity Framework et au-delà. C'est le .NET le plus rapide de tous les temps », poursuit-il.
Configuration
Les microbenchmarks utilisés dans ce post utilisent benchmarkdotnet. Pour suivre facilement sa propre validation, voici une configuration très simple pour les benchmarks. Créez un nouveau projet C# :
Code : | Sélectionner tout |
1 2 | dotnet new console -o benchmarks cd benchmarks |
Le nouveau répertoire benchmarks contiendra un fichier benchmarks.csproj et un fichier Program.cs. Remplacez le contenu de benchmarks.csproj par ceci :
Code XML : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFrameworks>net7.0;net6.0</TargetFrameworks> <LangVersion>Preview</LangVersion> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ServerGarbageCollection>true</ServerGarbageCollection> </PropertyGroup> <ItemGroup> <PackageReference Include="benchmarkdotnet" Version="0.13.2" /> </ItemGroup> </Project> |
Pour chaque benchmark inclus dans cet article, il est possible de copier et coller le code dans cette classe de test, et exécuter les benchmarks. Par exemple, pour exécuter un benchmark comparant les performances sur .NET 6 et .NET 7 :
dotnet run -c Release -f net6.0 --filter '**' --runtimes net6.0 net7.0
Cette commande dit « construire les benchmarks en ciblant .NET 6, puis exécuter tous les benchmarks à la fois sur .NET 6 et .NET 7 ». Ou pour exécuter uniquement sur .NET 7 :
dotnet run -c Release -f net7.0 --filter '**' --runtimes net7.0
qui, au lieu de cela, construit en ciblant la surface de .NET 7 et ne s'exécute qu'une seule fois contre .NET 7. Vous pouvez effectuer cette opération sous Windows, Linux ou macOS. Sauf indication contraire (par exemple, lorsque les améliorations sont spécifiques à Unix et que j'exécute les benchmarks sur Linux), les résultats que partagés ici ont été enregistrés sur Windows 11 64 bits mais ne sont pas spécifiques à Windows et devraient montrer des différences relatives similaires sur les autres systèmes d'exploitation également.
La publication de la première version candidate de .NET 7 est imminente. Toutes les mesures présentées dans cet article ont été recueillies avec une version quotidienne récente de .NET 7 RC1. Notons qu'il s'agit de microbenchmarks. On s'attend à ce que des matériels différents, des versions différentes et des systèmes d'exploitation puissent affecter les chiffres en question.
Regex
Avant .NET 5, l'implémentation de Regex n'avait pratiquement pas été modifiée pendant un certain temps. Dans .NET 5, Microsoft l'a ramenée à un niveau équivalent, voire supérieur, à celui de plusieurs autres implémentations de l'industrie du point de vue des performances. Avec .NET 7, Microsoft a fait un grand pas en avant.
RegexOptions.NonBacktracking (non retour en arrière)
Stephen Toub commence par présenter l'une des plus grandes nouveautés de Regex, la nouvelle implémentation de RegexOptions.NonBacktracking. Selon le développeur .NET, RegexOptions.NonBacktracking bascule le traitement de Regexvers l'utilisation d'un nouveau moteur basé sur les automates finis. Il a deux modes d'exécution principaux, l'un qui repose sur les DFA (automates finis déterministes) et l'autre sur les NFA (automates finis non déterministes). Les deux implémentations offrent une garantie très précieuse : le temps de traitement est linéaire par rapport à la longueur de l'entrée.
Alors qu'un moteur de backtracking (qui est ce que Regex utilise si NonBacktracking n'est pas spécifié) peut rencontrer une situation connue sous le nom de backtracking catastrophique, où des expressions problématiques combinées avec une entrée problématique peuvent résulter en un traitement exponentiel dans la longueur de l'entrée, NonBacktracking garantit qu'il ne fera jamais qu'une quantité de travail constante amortie par caractère dans l'entrée. Dans le cas d'un DFA, cette constante est très petite. Avec un NFA, cette constante peut être beaucoup plus grande, en fonction de la complexité du motif, mais pour un motif donné, le travail reste linéaire par rapport à la longueur de l'entrée.
Un nombre important d'années de développement a été consacré à l'implémentation de NonBacktracking, qui a été initialement ajoutée à dotnet/runtime dans dotnet/runtime#60607. Cependant, la recherche et l'implémentation originales de cette implémentation provenaient en fait de Microsoft Research (MSR), et étaient disponibles sous la forme d'un paquetage expérimental sous la forme de la bibliothèque Symbolic Regex Matcher (SRM) publiée par MSR. Il est encore possible d'en voir des vestiges dans le code actuel de .NET 7, mais il a évolué de manière significative, en étroite collaboration entre les développeurs de l'équipe .NET et les chercheurs de MSR (avant d'être intégré dans dotnet/runtime, il a été incubé pendant plus d'un an dans dotnet/runtimelab, où le code SRM original a été introduit via dotnet/runtimelab#588 ).
Cette implémentation est basée sur la notion de dérivés d'expressions régulières, un concept qui existe depuis des décennies et qui a été considérablement amélioré pour cette implémentation. Les dérivés de Regexconstituent la base de la construction des automates utilisés pour traiter les entrées. L'idée de base est assez simple : prenez une regex et traitez un seul caractère... quelle est la nouvelle regex que vous obtenez pour décrire ce qui reste après avoir traité ce seul caractère ? C'est la dérivée.
Par exemple, si l'on prend la regex \w{3} pour correspondre à trois caractères de mot, si on l'applique au prochain caractère d'entrée 'a', le premier \w sera supprimé, ce qui nous laissera le dérivé \w{2}. Simple, non ? Qu'en est-il de quelque chose de plus compliqué, comme l'expression .*(the|he). Que se passe-t-il si le prochain caractère est un t ? Eh bien, il est possible que le t soit consommé par le .* au début du motif, auquel cas la regex restante serait exactement la même que celle de départ (.*(the|he)), puisqu'après avoir fait correspondre le t, nous pourrions toujours faire correspondre exactement la même entrée que sans le t. Mais, le t pourrait aussi avoir fait partie de la correspondance avec the, et appliqué au the, nous enlèverions le t et il resterait le he, donc maintenant notre dérivé est .*(the|he)|he. Mais qu'en est-il du he dans l'alternance originale ? t ne correspond pas à h, donc la dérivée ne serait rien, ce que nous exprimerons ici comme une classe de caractères vide, nous donnant .*(the|he)|he|[]. Bien sûr, dans le cadre d'une alternance, ce "rien" à la fin est un nop, et nous pouvons donc simplifier l'ensemble de la dérivation en .*(the|he)|he... terminé. C'était tout en appliquant le modèle original contre un t suivant. Et si c'était contre un h à la place ? En suivant la même logique que pour le t, on obtient cette fois .*(the|he)|e. Et ainsi de suite. Et si on commençait plutôt par le dérivé du h et que le caractère suivant était un e ? Dans ce cas, nous prenons le motif .*(the|he)|e et l'appliquons à e. Dans le cas du côté gauche de l'alternance, il peut être consommé par le .* (mais ne correspond ni à t ni à h), et nous nous retrouvons donc avec la même sous-expression. Mais contre le côté droit de l'alternance, e correspond à e, ce qui nous laisse avec la chaîne vide () : .*(the|he)|(). Lorsqu'un motif est "nullable" (il peut correspondre à la chaîne vide), il peut être considéré comme une correspondance. Nous pouvons visualiser le tout sous la forme d'un graphique, avec des transitions pour chaque caractère d'entrée vers la dérivée qui résulte de son application.
Selon Stephen Toub, c'est exactement comme cela que NonBacktracking construit les DFAs qu'il utilise pour traiter les entrées. Pour chaque construction de regex (concaténations, alternances, boucles, etc.), le moteur sait comment dériver la regex suivante sur la base du caractère évalué. Cette application est effectuée de manière paresseuse, de sorte que nous avons un état de départ initial (le motif original), puis lorsque nous évaluons le prochain caractère en entrée, il regarde s'il existe déjà un dérivé disponible pour cette transition : s'il y en a un, il le suit, et s'il n'y en a pas, il dérive dynamiquement/lentement le prochain nœud du graphe. En gros, c'est comme ça que ça fonctionne.
Stephen Touble montre qu'il existe une tonne de complications et d'ingéniosité pour rendre le moteur efficace. L'un de ces exemples est le compromis entre la consommation de mémoire et le débit. Étant donné la possibilité d'avoir n'importe quel caractère en entrée, il est possible d'avoir effectivement ~65000 transitions à partir de chaque nœud (par exemple, chaque nœud pourrait avoir besoin d'une table d'éléments ~65000) ; cela augmenterait considérablement la consommation de mémoire.
Cependant, si on a réellement autant de transitions, il est très probable qu'une majorité significative d'entre elles pointeraient vers le même noeud cible. Ainsi, NonBacktracking maintient ses propres regroupements de caractères dans ce qu'il appelle des minterms. Si deux caractères ont exactement la même transition, ils font partie du même minterm. Les transitions sont alors construites en termes de mintermes, avec au maximum une transition par minterm à partir d'un nœud donné. Lorsque le prochain caractère d'entrée est lu, il est associé à un ID de minterm, puis trouve la transition appropriée pour cet ID ; un niveau supplémentaire d'indirection afin d'économiser une quantité potentiellement énorme de mémoire. Ce mappage est géré via un tableau de bitmaps pour l'ASCII et une structure de données efficace connue sous le nom de diagramme de décision binaire (BDD) pour tout ce qui est supérieur à 0x7F.
Comme indiqué, le moteur non rétroactif est linéaire par rapport à la longueur de l'entrée. Mais cela ne signifie pas qu'il examine toujours chaque caractère en entrée exactement une fois. Si Regex.IsMatch, c'est le cas ; après tout, IsMatch a seulement besoin de déterminer s'il y a une correspondance et n'a pas besoin de calculer d'autres informations, comme l'endroit où la correspondance commence ou se termine, toute information sur les captures, etc. Ainsi, le moteur peut simplement utiliser ses automates pour parcourir le long de l'entrée, en se déplaçant de nœud en nœud dans le graphe jusqu'à ce qu'il arrive à un état final ou qu'il n'ait plus d'entrée.
D'autres opérations, cependant, exigent qu'il recueille plus d'informations. Regex.Match doit tout calculer, ce qui peut impliquer de multiples déplacements sur l'entrée. Dans l'implémentation initiale, l'équivalent de Match devait toujours effectuer trois passages : faire correspondre vers l'avant pour trouver la fin d'une correspondance, puis faire correspondre une copie inversée du motif à l'envers à partir de cette position de fin afin de trouver où la correspondance commence réellement, et enfin parcourur encore une fois vers l'avant à partir de cette position de départ connue pour trouver la position de fin réelle.
Cependant, avec dotnet/runtime#68199, à moins que des captures ne soient nécessaires, cela peut maintenant être fait en deux passes seulement : une fois en avant pour trouver la position finale garantie de la correspondance, et ensuite une fois en arrière pour trouver sa position de départ. Et dotnet/runtime#65129 a ajouté le support des captures, que l'implémentation originale n'avait pas non plus.
Cette prise en charge des captures ajoute une troisième passe, de sorte qu'une fois que les limites du match sont connues, le moteur exécute la passe avant une fois de plus, mais cette fois avec une "simulation" basée sur la NFA qui est capable d'enregistrer les « effets de capture » sur les transitions. Tout ceci permet à l'implémentation du nonbacktracking d'avoir exactement la même sémantique que les moteurs de backtracking, produisant toujours les mêmes correspondances dans le même ordre avec les mêmes informations de capture. La seule différence à cet égard est que, alors qu'avec les moteurs backtracking, les groupes de capture à l'intérieur des boucles stockent toutes les valeurs capturées à chaque itération de la boucle, seule la dernière itération est stockée avec l'implémentation non backtracking. En plus de cela, il y a quelques constructions que l'implémentation non backtracking ne supporte tout simplement pas, de sorte que toute tentative d'utiliser l'une d'entre elles échouera lors de la construction de la Regex, par exemple les rétro-références et les lookarounds.
JIT
Il est essentiel de pouvoir comprendre exactement quel code assembleur est généré par le JIT lorsqu'il s'agit d'affiner le code de bas niveau sensible aux performances. Il existe plusieurs façons d'accéder à ce code assembleur. L'outil en ligne sharplab.io est incroyablement utile pour cela ; cependant, il ne cible actuellement qu'une seule version, donc actuellement, nous ne pouvons voir que le résultat pour .NET 6, ce qui le rend difficile à utiliser pour des comparaisons A/B. Les solutions les plus flexibles impliquent d'obtenir ce code d'assemblage localement, car cela permet de comparer toutes les versions ou constructions locales souhaitées avec toutes les configurations et tous les commutateurs.
Une approche courante consiste à utiliser le [DisassemblyDiagnoser] de benchmarkdotnet. Il suffit d'ajouter l'attribut [DisassemblyDiagnoser] à votre classe de test : benchmarkdotnet trouvera le code d'assemblage généré pour vos tests et une partie des fonctions qu'ils appellent, et affichera le code d'assemblage trouvé sous une forme lisible par l'homme. Par exemple, ce test est lancé :
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 | using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; using System; [DisassemblyDiagnoser] public partial class Program { static void Main(string[] args) => BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); private int _a = 42, _b = 84; [Benchmark] public int Min() => Math.Min(_a, _b); |
avec :
dotnet run -c Release -f net7.0 --filter '**'
En plus de l'exécution normale des tests et de la synchronisation, benchmarkdotnet produit également un fichier Program-asm.md qui contient ceci :
Code asm : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 | ; Program.Min() mov eax,[rcx+8] mov edx,[rcx+0C] cmp eax,edx jg short M00_L01 mov edx,eax M00_L00: mov eax,edx ret M00_L01: jmp short M00_L00 ; Total bytes of code 17 |
Cette prise en charge a été récemment améliorée dans dotnet/benchmarkdotnet#2072, qui permet de passer une liste de filtres en ligne de commande à benchmarkdotnet pour lui dire exactement quel code d'assemblage des méthodes doit être vidé.
Avec une version "déboguée" ou "vérifiée" du runtime .NET ("vérifiée" est une version dont les optimisations sont activées mais qui inclut toujours les assertions), et plus particulièrement de clrjit.dll, une autre approche intéressante consiste à définir une variable d'environnement qui permet au JIT lui-même de produire une description lisible par les humains de tout le code d'assemblage qu'il émet. Cette méthode peut être utilisée avec n'importe quel type d'application, car elle fait partie du JIT lui-même plutôt que d'un outil spécifique ou d'un autre environnement, elle permet de montrer le code que le JIT génère à chaque fois qu'il génère du code (par exemple, s'il compile d'abord une méthode sans optimisation, puis la recompile plus tard avec optimisation). Le (gros) inconvénient, bien sûr, est qu'il nécessite une version non publiée du runtime, ce qui signifie généralement que l'utilisateur doit le construire soi-même à partir des sources du dépôt dotnet/runtime.
À partir de dotnet/runtime#73365, le support du dumping d'assemblage est maintenant disponible dans les versions publiées également, ce qui signifie qu'il fait simplement partie de .NET 7 et que l'utilisateur n'a pas besoin de quelque chose de spécial pour l'utiliser. Pour s'en convaincre, essayons de créer une application simple du type « hello world » :
Code : | Sélectionner tout |
1 2 3 4 5 6 | using System; class Program { public static void Main() => Console.WriteLine("Hello, world!"); } |
$env:DOTNET_JitDisasm="Main"
Dans la console :
Code asm : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | ; Assembly listing for method Program:Main() ; Emitting BLENDED_CODE for X64 CPU with AVX - Windows ; Tier-0 compilation ; MinOpts code ; rbp based frame ; partially interruptible G_M000_IG01: ;; offset=0000H 55 push rbp 4883EC20 sub rsp, 32 488D6C2420 lea rbp, [rsp+20H] G_M000_IG02: ;; offset=000AH 48B9D820400A8E010000 mov rcx, 0x18E0A4020D8 488B09 mov rcx, gword ptr [rcx] FF1583B31000 call [Console:WriteLine(String)] 90 nop G_M000_IG03: ;; offset=001EH 4883C420 add rsp, 32 5D pop rbp C3 ret ; Total bytes of code 36 Hello, world! |
Cela est extrêmement utile pour l'analyse et le réglage des performances, même pour des questions aussi simples.
Remplacement On-Stack - OSR
Le remplacement à la volée ou OSR est l'une des fonctionnalités les plus intéressantes du JIT dans .NET 7. Mais pour bien comprendre l'OSR, il faut d'abord comprendre la compilation étagée.
L'un des problèmes auxquels un environnement géré avec un compilateur JIT doit faire face est le compromis entre le démarrage et le débit. Historiquement, le travail d'un compilateur d'optimisation est d'optimiser, afin de permettre le meilleur débit possible de l'application ou du service une fois en cours d'exécution. Mais une telle optimisation nécessite une analyse, prend du temps, et l'exécution de tout ce travail entraîne une augmentation du temps de démarrage, car tout le code sur le chemin de démarrage (par exemple, tout le code qui doit être exécuté avant qu'un serveur web puisse répondre à la première demande) doit être compilé.
Un compilateur JIT doit donc faire des compromis : un meilleur débit au prix d'un temps de démarrage plus long, ou un meilleur temps de démarrage au prix d'un débit réduit. Pour certains types d'applications et de services, le compromis est facile à faire, par exemple si le service démarre une fois et fonctionne pendant des jours, quelques secondes supplémentaires de temps de démarrage n'ont pas d'importance, ou si on a une application console qui va faire un calcul rapide et sortir, le temps de démarrage est tout ce qui compte.
Mais comment le JIT peut-il savoir dans quel scénario il se trouve, et voulons-nous vraiment que chaque développeur doive connaître ce genre de paramètres et de compromis et configurer chacune de ses applications en conséquence ? L'une des réponses à cette question a été la compilation en amont sur le temps, qui a pris diverses formes dans .NET. Par exemple, toutes les bibliothèques de base sont crossgen, c'est-à-dire qu'elles ont été exécutées par un outil qui produit le format R2R, ce qui permet d'obtenir des binaires contenant du code d'assemblage qui ne nécessite que des modifications mineures pour être réellement exécuté ; le code ne peut pas être généré pour chaque méthode, mais suffisamment pour réduire de manière significative le temps de démarrage.
Bien sûr, ces approches ont leurs propres inconvénients, par exemple, l'une des promesses d'un compilateur JIT est qu'il peut tirer parti de la connaissance de la machine / du processus actuel afin de l'optimiser au mieux. Ainsi, par exemple, les images R2R doivent supposer un certain jeu d'instructions de base (par exemple, quelles instructions de vectorisation sont disponibles) alors que le JIT peut voir ce qui est réellement disponible et utiliser le meilleur. La « compilation hiérarchisée » apporte une autre réponse, utilisable avec ou sans ces autres solutions de compilation en avance sur le temps (AOT).
L''idée de la compilation hiérarchisé est simple : elle permet au JIT de compiler le même code plusieurs fois. La première fois, le JIT peut utiliser aussi peu d'optimisations que possible (une poignée d'optimisations peuvent en fait rendre le débit du JIT plus rapide, donc il est toujours utile de les appliquer), produisant un code assembleur relativement peu optimisé mais très rapide. Et lorsqu'il le fait, il peut ajouter des instruments dans l'assemblage pour suivre la fréquence d'appel des méthodes.
Il s'avère que de nombreuses fonctions utilisées sur un chemin de démarrage ne sont invoquées qu'une fois ou peut-être une poignée de fois, et il faudrait plus de temps pour les optimiser que pour les exécuter sans optimisation. Ensuite, lorsque l'instrumentation de la méthode déclenche un certain seuil, par exemple une méthode ayant été exécutée 30 fois, un élément du travail est mis en file d'attente pour recompiler cette méthode, mais cette fois avec toutes les optimisations que le JIT peut lui apporter. C'est ce qu'on appelle affectueusement le tiering up.
Une fois la recompilation terminée, les sites d'appel de la méthode sont corrigés avec l'adresse du nouveau code assembleur hautement optimisé, et les invocations futures emprunteront le chemin rapide. Ainsi, nous obtenons un démarrage plus rapide et un débit soutenu plus rapide. Du moins, c'est ce que l'on espère.
Les méthodes qui n'entrent pas dans ce moule posent toutefois problème. S'il est vrai que de nombreuses méthodes sensibles aux performances sont relativement rapides et exécutées de très nombreuses fois, il existe également un grand nombre de méthodes sensibles aux performances qui ne sont exécutées qu'une poignée de fois, voire une seule, mais dont l'exécution prend beaucoup de temps, voire la durée de tout le processus : les méthodes avec boucles. Par conséquent, par défaut, la compilation étagée n'est pas appliquée aux boucles, bien qu'elle puisse être activée en fixant la variable d'environnement DOTNET_TC_QuickJitForLoops à 1. Les effets peuvent être observés en essayant cette application console simple avec .NET 6. Avec les paramètres par défaut :
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | class Program { static void Main() { var sw = new System.Diagnostics.Stopwatch(); while (true) { sw.Restart(); for (int trial = 0; trial < 10_000; trial++) { int count = 0; for (int i = 0; i < char.MaxValue; i++) if (IsAsciiDigit((char)i)) count++; } sw.Stop(); Console.WriteLine(sw.Elapsed); } static bool IsAsciiDigit(char c) => (uint)(c - '0') <= 9; } } |
L'application est ensuite recompilée, en renvoyant les résultats de l'instrumentation au compilateur, ce qui lui permet d'optimiser l'application en fonction de la manière dont elle est censée être utilisée. Cette approche de PGO est appelée PGO statique, car les informations sont glanées avant le déploiement réel, et c'est quelque chose que .NET fait sous diverses formes depuis des années. Cependant, selon Stephen Toub, le développement vraiment intéressant de .NET est le PGO dynamique, qui a été introduit dans .NET 6, mais désactivé par défaut.
PGO dynamique tire parti de la compilation à plusieurs niveaux. Le JIT instrumenterait le code de niveau 0 pour savoir combien de fois la méthode est appelée, ou dans le cas des boucles, combien de fois la boucle s'exécute. Il peut également l'instrumenter pour d'autres choses. Par exemple, il peut suivre exactement quels types concrets sont utilisés comme cible d'une répartition d'interface, et ensuite, au niveau 1, spécialiser le code pour qu'il s'attende aux types les plus courants (c'est ce qu'on appelle la « dévirtualisation gardée » ou GDV). Vous pouvez voir cela dans ce petit exemple. Avec .NET 7, en donnant la valeur 1 à la variable d'environnement DOTNET_TieredPGO sur exemple ci-dessous,
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | class Program { static void Main() { IPrinter printer = new Printer(); for (int i = 0; ; i++) { DoWork(printer, i); } } static void DoWork(IPrinter printer, int i) { printer.PrintIfTrue(i == int.MaxValue); } interface IPrinter { void PrintIfTrue(bool condition); } class Printer : IPrinter { public void PrintIfTrue(bool condition) { if (condition) Console.WriteLine("Print!"); } } } |
Avec PGO désactivé, nous obtenons le même débit de performance pour .NET 6 et .NET 7 :
Mais le tableau change lorsque nous activons le PGO dynamique (DOTNET_TieredPGO=1). .NET 6 devient ~14% plus rapide, mais .NET 7 devient ~3x plus rapide !
dotnet/runtime#70377 est une autre amélioration précieuse de PGO dynamique, qui permet à PGO de jouer agréablement avec le clonage de boucle. Pour mieux comprendre cela, une brève digression sur ce que sont ces éléments. Le clonage de boucle est un mécanisme que le JIT utilise pour éviter diverses surcharges dans le chemin rapide d'une boucle. Considérons la méthode Test dans cet exemple :
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | using System.Runtime.CompilerServices; class Program { static void Main() { int[] array = new int[10_000_000]; for (int i = 0; i < 1_000_000; i++) { Test(array); } } [MethodImpl(MethodImplOptions.NoInlining)] private static bool Test(int[] array) { for (int i = 0; i < 0x12345; i++) { if (array[i] == 42) { return true; } } return false; } } |
Le JIT ne sait pas si le tableau passé est suffisamment long pour que tous les accès à array[i] à l'intérieur de la boucle soient dans les limites, et il devrait donc injecter des contrôles de limites pour chaque accès. Alors qu'il serait agréable de simplement faire la vérification de la longueur en amont et de lancer une exception si la longueur n'est pas suffisante, cela pourrait également modifier le comportement (imaginez que la méthode écrive dans le tableau au fur et à mesure ou qu'elle modifie un état partagé). Au lieu de cela, le JIT utilise le « clonage de boucle ». Il réécrit essentiellement cette méthode Test pour qu'elle ressemble davantage à ceci :
Code : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | if (array is not null && array.Length >= 0x12345) { for (int i = 0; i < 0x12345; i++) { if (array[i] == 42) // no bounds checks emitted for this access :-) { return true; } } } else { for (int i = 0; i < 0x12345; i++) { if (array[i] == 42) // bounds checks emitted for this access :-( { return true; } } } return false; |
Source : Microsoft
Et vous ?
Trouvez-vous pertinent l'analyse de Stephen Toub, sur les performances de .NET 7
Partagez-vous l'avis selon lequel, .NET 7 est la version la plus rapide de tous les temps ?
Quelle technologie utilisez-vous pour le développement d"applications ? Quelle appréciation faites vous de .NET 7 ?
Quel aspect du framework vous intéresse le plus ? Sur quel point attendez-vous de voir des améliorations ?
Voir aussi :
.NET 6 apporterait jusqu'à 40 % de gain de vitesse par rapport .NET 5, les tests de mise en cache et de boucles serrées serraient jusqu'à 30 % plus rapide
Microsoft annonce la disponibilité de .NET 6 Preview 4 qui apporte l'expérience Hot Reload, à Visual Studio et aux outils de ligne de commande
Google Chrome et Microsoft Edge prendront en charge la fonction de sécurité CET d'Intel, pour maintenir la sécurité et prévenir des vulnérabilités
Visual Studio 2022 Preview 4 est disponible et s'accompagne d'améliorations sur la productivité personnelle et d'équipe, le chargement à chaud dans ASP.NET Core et la gestion de thèmes
Red Hat annonce un RHEL sans frais pour les petits environnements de production, notamment pour les charges de travail de production avec jusqu'à 16 serveurs de production