Partager via


Nouveautés du runtime .NET 10

Cet article décrit les nouvelles fonctionnalités et améliorations des performances dans le runtime .NET pour .NET 10. Il est mis à jour pour preview 5.

Dévirtualisation de la méthode d'interface de tableau

L’un des domaines d’intérêt pour .NET 10 est de réduire la surcharge d’abstraction des fonctionnalités de langage populaires. Dans la poursuite de cet objectif, la capacité du JIT à dévirtualiser les appels de méthode s’est développée pour couvrir les méthodes d’interface de tableau.

Considérons l'approche classique consistant à parcourir un tableau en boucle :

static int Sum(int[] array)
{
    int sum = 0;
    for (int i = 0; i < array.Length; i++)
    {
        sum += array[i];
    }
    return sum;
}

Cette forme de code est facile à optimiser pour le JIT, principalement parce qu’il n’y a pas d’appels virtuels à prendre en compte. Au lieu de cela, le JIT peut se concentrer sur la suppression des vérifications des limites sur l’accès au tableau et l’application des optimisations de boucle qui ont été ajoutées dans .NET 9. L’exemple suivant ajoute quelques appels virtuels :

static int Sum(int[] array)
{
    int sum = 0;
    IEnumerable<int> temp = array;

    foreach (var num in temp)
    {
        sum += num;
    }
    return sum;
}

Le type de la collection sous-jacente est clair, et le JIT devrait être en mesure de transformer cet extrait de code en le premier. Toutefois, les interfaces de tableau sont implémentées différemment des interfaces « normales », de sorte que le JIT ne sait pas comment les dévirtualiser. Cela signifie que les appels à l'énumérateur dans la boucle foreach restent virtuels, bloquant ainsi plusieurs optimisations telles que l'inlining et l'allocation de pile.

À partir de .NET 10, le JIT peut dévirtualiser et incorporer les méthodes de l'interface de tableau. Il s’agit de la première des nombreuses étapes permettant d’atteindre la parité des performances entre les implémentations, comme détaillé dans les plans de dé-abstraction .NET 10.

Désabstraction des énumérations de tableaux

Les efforts visant à réduire la charge d'abstraction de l'itération des tableaux via les énumérateurs ont permis d'améliorer les capacités d'incorporation, d'allocation de pile et de clonage de boucle de la compilation JIT. Par exemple, la surcharge liée à l'énumération de tableaux via IEnumerable est réduite, et l'analyse d'échappement conditionnel permet désormais l'allocation de pile d'énumérateurs dans certains scénarios.

Disposition améliorée du code

Le compilateur JIT dans .NET 10 introduit une nouvelle approche de l’organisation du code de méthode en blocs de base pour améliorer les performances du runtime. Auparavant, le JIT utilisait un parcours en ordre inverse post-ordre du graphe de flux du programme comme disposition initiale, suivi de transformations itératives. Bien qu'efficace, cette approche présentait des limites dans la modélisation des compromis entre la réduction des branches et l'augmentation de la densité de code actif.

Dans .NET 10, le JIT modélise le problème de réorganisation des blocs comme une réduction du problème du voyageur de commerce asymétrique et met en œuvre l'heuristique 3-opt pour trouver un parcours quasi optimal. Cette optimisation améliore la densité des chemins chauds et réduit les distances entre les branches, ce qui se traduit par de meilleures performances d'exécution.

Prise en charge de AVX10.2

.NET 10 introduit la prise en charge des extensions de vecteur avancées (AVX) 10.2 pour les processeurs x64. Les nouvelles intrinsèques disponibles dans la System.Runtime.Intrinsics.X86.Avx10v2 classe peuvent être testées une fois que le matériel compatible est disponible.

Étant donné que le matériel compatible AVX10.2 n’est pas encore disponible, la prise en charge du JIT pour AVX10.2 est actuellement désactivée par défaut.

Allocation de piles

L’allocation de pile réduit le nombre d’objets que le GC doit suivre et déverrouille également d’autres optimisations. Par exemple, après qu'un objet a été alloué sur la pile, la compilation JIT peut envisager de le remplacer entièrement par ses valeurs scalaires. Pour cette raison, l'allocation de la pile est essentielle pour réduire la pénalité d'abstraction des types de référence. .NET 10 ajoute l’allocation de pile pour les petits tableaux de types valeuretles petits tableaux de types référence. Elle inclut également l’analyse d’échappement pour les champs struct locaux et les délégués. (Les objets qui ne peuvent pas s’échapper peuvent être alloués sur la pile.)

Petits tableaux de types de valeurs

Le JIT alloue désormais dans la pile des tableaux de petite taille et de taille fixe de types de valeurs qui ne contiennent pas de pointeurs GC lorsque leur durée de vie ne dépasse pas celle de la méthode parente. Dans l’exemple suivant, le JIT sait au moment de la compilation que numbers est un tableau de seulement trois entiers qui n’est pas utilisé après un appel à Sum, et l’alloue donc sur la pile.

static void Sum()
{
    int[] numbers = {1, 2, 3};
    int sum = 0;

    for (int i = 0; i < numbers.Length; i++)
    {
        sum += numbers[i];
    }

    Console.WriteLine(sum);
}

Petits tableaux de types de références

.NET 10 étend les améliorations apportées à l’allocation de pile .NET 9 aux petits tableaux de types de référence. Auparavant, les tableaux de types de référence étaient toujours alloués dans le tas, même lorsque leur durée de vie était limitée à une seule méthode. Désormais, le JIT peut allouer ces tableaux en pile lorsqu'il détermine qu'ils ne survivent pas à leur contexte de création. Dans l’exemple suivant, le tableau words est désormais alloué sur la pile.

static void Print()
{
    string[] words = {"Hello", "World!"};
    foreach (var str in words)
    {
        Console.WriteLine(str);
    }
}

Analyse d’échappement

L’analyse d’échappement détermine si un objet peut survivre à sa méthode parente. Les objets « escape » lorsqu’ils sont affectés à des variables non locales ou transmis à des fonctions non incorporées par le JIT. Si un objet ne peut pas s’échapper, il peut être alloué sur la pile. .NET 10 inclut l’analyse d’échappement pour :

Champs de struct locaux

À compter de .NET 10, le JIT prend en compte les objets référencés par les champs de struct, ce qui permet davantage d’allocations de pile et réduit la surcharge de tas. Prenons l’exemple suivant :

public class Program
{
    struct GCStruct
    {
        public int[] arr;
    }

    public static void Main()
    {
        int[] x = new int[10];
        GCStruct y = new GCStruct() { arr = x };
        return y.arr[0];
    }
}

Normalement, la pile JIT alloue de petits tableaux de taille fixe qui ne s’échappent pas, tels que x. Son affectation à y.arr ne provoque pas x de fuite, car y n'échappe pas non plus. Toutefois, l'implémentation précédente de l'analyse d'échappement du JIT ne modélisait pas les références aux champs de structure. Dans .NET 9, l’assembly x64 généré pour Main inclut un appel à CORINFO_HELP_NEWARR_1_VC pour allouer x sur le segment, indiquant qu’elle a été marquée comme échappement :

Program:Main():int (FullOpts):
       push     rax
       mov      rdi, 0x719E28028A98      ; int[]
       mov      esi, 10
       call     CORINFO_HELP_NEWARR_1_VC
       mov      eax, dword ptr [rax+0x10]
       add      rsp, 8
       ret

Dans .NET 10, le JIT ne marque plus les objets référencés par les champs de struct locaux comme échapant, tant que le struct en question n’échappe pas. L’assembly ressemble maintenant à ceci (notez que l’appel d’assistance à l’allocation de tas a disparu) :

Program:Main():int (FullOpts):
       sub      rsp, 56
       vxorps   xmm8, xmm8, xmm8
       vmovdqu  ymmword ptr [rsp], ymm8
       vmovdqa  xmmword ptr [rsp+0x20], xmm8
       xor      eax, eax
       mov      qword ptr [rsp+0x30], rax
       mov      rax, 0x7F9FC16F8CC8      ; int[]
       mov      qword ptr [rsp], rax
       lea      rax, [rsp]
       mov      dword ptr [rax+0x08], 10
       lea      rax, [rsp]
       mov      eax, dword ptr [rax+0x10]
       add      rsp, 56
       ret

Pour plus d’informations sur les améliorations de la dé-abstraction dans .NET 10, consultez dotnet/runtime#108913.

Délégués

Lorsque le code source est compilé vers IL, chaque délégué est transformé en une classe de clôture avec une méthode correspondant à la définition du délégué et des champs correspondant aux variables capturées. Au moment de l’exécution, un objet de fermeture est créé pour instancier les variables capturées, ainsi qu’un Func objet pour appeler le délégué. Si l’analyse d’échappement détermine que l’objet Func ne survit pas à son étendue actuelle, le JIT l’alloue sur la pile.

Considérez la méthode Main suivante :

 public static int Main()
{
    int local = 1;
    int[] arr = new int[100];
    var func = (int x) => x + local;
    int sum = 0;

    foreach (int num in arr)
    {
        sum += func(num);
    }

    return sum;
}

Auparavant, le JIT produit l’assembly x64 abrégé suivant pour Main. Avant d’entrer dans la boucle, arr, func, et la classe de fermeture pour func appelée Program+<>c__DisplayClass0_0 sont toutes allouées sur le tas, comme indiqué par les CORINFO_HELP_NEW* appels.

       ; prolog omitted for brevity
       mov      rdi, 0x7DD0AE362E28      ; Program+<>c__DisplayClass0_0
       call     CORINFO_HELP_NEWSFAST
       mov      rbx, rax
       mov      dword ptr [rbx+0x08], 1
       mov      rdi, 0x7DD0AE268A98      ; int[]
       mov      esi, 100
       call     CORINFO_HELP_NEWARR_1_VC
       mov      r15, rax
       mov      rdi, 0x7DD0AE4A9C58      ; System.Func`2[int,int]
       call     CORINFO_HELP_NEWSFAST
       mov      r14, rax
       lea      rdi, bword ptr [r14+0x08]
       mov      rsi, rbx
       call     CORINFO_HELP_ASSIGN_REF
       mov      rsi, 0x7DD0AE461140      ; code for Program+<>c__DisplayClass0_0:<Main>b__0(int):int:this
       mov      qword ptr [r14+0x18], rsi
       xor      ebx, ebx
       add      r15, 16
       mov      r13d, 100
G_M24375_IG03:  ;; offset=0x0075
       mov      esi, dword ptr [r15]
       mov      rdi, gword ptr [r14+0x08]
       call     [r14+0x18]System.Func`2[int,int]:Invoke(int):int:this
       add      ebx, eax
       add      r15, 4
       dec      r13d
       jne      SHORT G_M24375_IG03
       ; epilog omitted for brevity

Maintenant, étant donné que func n’est jamais référencée en dehors de la portée de Main, elle est également allouée sur la pile :

       ; prolog omitted for brevity
       mov      rdi, 0x7B52F7837958      ; Program+<>c__DisplayClass0_0
       call     CORINFO_HELP_NEWSFAST
       mov      rbx, rax
       mov      dword ptr [rbx+0x08], 1
       mov      rsi, 0x7B52F7718CC8      ; int[]
       mov      qword ptr [rbp-0x1C0], rsi
       lea      rsi, [rbp-0x1C0]
       mov      dword ptr [rsi+0x08], 100
       lea      r15, [rbp-0x1C0]
       xor      r14d, r14d
       add      r15, 16
       mov      r13d, 100
G_M24375_IG03:  ;; offset=0x0099
       mov      esi, dword ptr [r15]
       mov      rdi, rbx
       mov      rax, 0x7B52F7901638      ; address of definition for "func"
       call     rax
       add      r14d, eax
       add      r15, 4
       dec      r13d
       jne      SHORT G_M24375_IG03
       ; epilog omitted for brevity

Remarquez qu’il existe un appel CORINFO_HELP_NEW* restant, qui est l’allocation de tas pour la fermeture. L’équipe du runtime prévoit d’étendre l’analyse des évitements pour prendre en charge l’allocation en pile des fermetures dans une future version.

Améliorations apportées à l’incorporation

Diverses améliorations d'intégration ont été apportées pour .NET 10.

La compilation JIT peut désormais incorporer des méthodes qui deviennent éligibles à la dévirtualisation en raison d'une intégration antérieure. Cette amélioration permet au JIT de découvrir davantage d’opportunités d’optimisation, telles que la mise en ligne et la dévirtualisation.

Certaines méthodes qui ont une sémantique de gestion des exceptions, en particulier celles avec des blocs try-finally, peuvent également être intégrées.

Afin de mieux tirer parti de la capacité du JIT à allouer certains tableaux à la pile, l’heuristique de l’inliner a été ajustée pour augmenter la rentabilité des candidats susceptibles de renvoyer des tableaux de petite taille et de taille fixe.

Types de retour

Pendant l’incorporation, le JIT met désormais à jour le type de variables temporaires qui contiennent des valeurs de retour. Si tous les sites de retour d'un appelant sont du même type, cette information précise sur le type est utilisée pour dévirtualiser les appels suivants. Cette amélioration complète celles apportées à la dévirtualisation différée et à la désabstraction des énumérations de tableaux.

Données de profil

.NET 10 améliore la stratégie d'inlignement du JIT pour optimiser l'utilisation des données de profil. Parmi de nombreuses heuristiques, l’inliner du JIT ne prend pas en compte les méthodes dépassant une certaine taille afin d’éviter d’alourdir la méthode de l’appelant. Lorsque l’appelant dispose de données de profil suggérant qu’un candidat à l’inlining est fréquemment exécuté, l’inliner augmente sa tolérance de taille pour le candidat.

Supposons que le JIT incorpore un appelé Callee sans données de profil dans un appelant Caller avec des données de profil. Cette divergence peut se produire si l’appelé est trop petit pour être instrumenté ou s’il est incorporé trop souvent pour avoir un nombre d’appels suffisant. Si Callee dispose de ses propres candidats à l’intégration, le JIT ne les a pas pris en compte auparavant avec sa limite de taille par défaut en raison de l’absence de données de profil pour Callee. À présent, le JIT réalise Caller qu’il dispose de données de profil et relâche sa restriction de taille (mais, pour tenir compte de la perte de précision, pas au même degré que si Callee des données de profil étaient présentes).

De même, lorsque le JIT décide qu’un site d’appel n’est pas rentable pour l’incorporation, il marque la méthode avec NoInlining pour empêcher que les futures tentatives d’incorporation ne la prennent en compte. Toutefois, de nombreuses heuristiques d'intégration en ligne sont sensibles aux données de profilage. Par exemple, le JIT peut décider qu'une méthode est trop grande pour être insérée sans données de profil. Mais lorsque l’appelant est suffisamment chaud, le JIT peut être prêt à assouplir sa restriction de taille et inline l’appel. Dans .NET 10, le JIT ne signale plus les inlines non rentables avec NoInlining pour éviter de pessimiser les sites d’appel avec des données de profil.

Améliorations apportées au mécanisme de pré-initialisation de type NativeAOT

Le préinitialiseur de type de NativeAOT prend désormais en charge toutes les variantes des codes d'opération conv.* et neg. Cette amélioration permet la préinitialisation des méthodes qui incluent des opérations de casting ou de négation, optimisant ainsi les performances d'exécution.

Améliorations apportées aux barrières d’écriture Arm64

Le récupérateur de mémoire (GC) de .NET est générationnel, ce qui signifie qu’il sépare les objets actifs par âge pour améliorer les performances de la collecte. Le récupérateur de mémoire collecte plus souvent les générations les plus jeunes, en partant du principe que les objets à longue durée de vie sont moins susceptibles d’être non référencés (ou « morts ») à un moment donné. Toutefois, supposons qu’un ancien objet commence à référencer un objet jeune ; le GC doit savoir qu’il ne peut pas collecter l’objet jeune. Cependant, avoir besoin de scanner les objets plus anciens pour collecter un objet jeune compromet les gains de performance d’un GC générationnel.

Pour résoudre ce problème, le JIT insère des barrières d’écriture avant les mises à jour des références d'objet pour informer le GC. Sur x64, le runtime peut basculer dynamiquement entre les implémentations de barrière d’écriture pour équilibrer les vitesses d’écriture et l’efficacité de la collecte, en fonction de la configuration du GC. Dans .NET 10, cette fonctionnalité est également disponible sur Arm64. En particulier, la nouvelle implémentation de barrière d’écriture par défaut sur Arm64 gère plus précisément les régions GC, ce qui améliore les performances de collection à un faible coût pour le débit de barrière d’écriture. Les benchmarks montrent des améliorations des pauses du récupérateur de mémoire de 8 % à plus de 20 % avec les nouvelles valeurs par défaut de ce dernier.