Dans un article rédigé le 22 novembre de l’année dernière, nous indiquons que .NET 6 pourrait apporter 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. Comme dans les versions précédentes de .NET, les performances sont un élément clé qui imprègne toute la pile de .NET. Aujourd’hui, alors que la communauté des développeurs .NET attend une version candidate de .NET 7, Microsoft annonce par le truchement de Stephen Toub, Développeur au sein de l'équipe .NET de Microsoft, l’amélioration des performances de .NET 7.« .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...
La fin de cet article est réservée aux abonnés. Soutenez le Club Developpez.com en prenant un abonnement pour que nous puissions continuer à vous proposer des publications.


