Condividi tramite


Novità del runtime di .NET 10

Questo articolo descrive le nuove funzionalità e i miglioramenti delle prestazioni nel runtime .NET per .NET 10.

Miglioramenti del compilatore JIT

Il compilatore JIT in .NET 10 include miglioramenti significativi che migliorano le prestazioni grazie a strategie di generazione e ottimizzazione del codice migliori.

Generazione di codice migliorata per gli argomenti dello struct

. Il compilatore JIT di NET è in grado di eseguire un'ottimizzazione denominata promozione fisica, in cui i membri di uno struct vengono inseriti nei registri anziché nello stack, eliminando gli accessi alla memoria. Questa ottimizzazione è particolarmente utile quando si passa uno struct a un metodo e la convenzione di chiamata richiede che i membri dello struct vengano passati nei registri.

.NET 10 migliora la rappresentazione interna del compilatore JIT per gestire i valori che condividono un registro. In precedenza, quando i membri dello struct dovevano essere compressi in un singolo registro, JIT archiviava prima i valori in memoria e quindi li caricava in un registro. Ora, il compilatore JIT può inserire direttamente i membri alzati di livello degli argomenti di struct in registri condivisi, eliminando le operazioni di memoria non necessarie.

Si consideri l'esempio seguente:

struct Point
{
    public long X;
    public long Y;

    public Point(long x, long y)
    {
        X = x;
        Y = y;
    }
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static void Consume(Point p)
{
    Console.WriteLine(p.X + p.Y);
}

private static void Main()
{
    Point p = new Point(10, 20);
    Consume(p);
}

In x64, i membri di Point vengono passati a Consume in registri separati e, dal momento che la promozione fisica è stata avviata per il locale p, non viene allocato nulla nello stack per primo:

Program:Main() (FullOpts):
       mov      edi, 10
       mov      esi, 20
       tail.jmp [Program:Consume(Program+Point)]

Si supponga ora che il tipo dei membri di Point sia stato modificato in int anziché long. Poiché un oggetto int è largo quattro byte e i registri hanno una larghezza di otto byte su x64, la convenzione di chiamata richiede che i membri di Point vengano passati in un unico registro. In precedenza, il compilatore JIT archiviava prima i valori in memoria e quindi caricava il blocco a otto byte in un registro. Con i miglioramenti apportati a .NET 10, il compilatore JIT può ora inserire direttamente i membri alzati di livello degli argomenti di struct in registri condivisi:

Program:Main() (FullOpts):
       mov      rdi, 0x140000000A
       tail.jmp [Program:Consume(Program+Point)]

In questo modo si elimina la necessità di archiviazione di memoria intermedia, con conseguente maggiore efficienza del codice assembly.

Inversione del ciclo migliorata

Il compilatore JIT può rialzare la condizione di un while ciclo e trasformare il corpo del ciclo in un do-while ciclo, producendo la forma finale:

if (loopCondition)
{
    do
    {
        // loop body
    } while (loopCondition);
}

Questa trasformazione viene chiamata inversione del ciclo. Spostando la condizione nella parte inferiore del ciclo, il JIT rimuove la necessità di ramificare all'inizio del ciclo per testare la condizione, migliorando il layout del codice. Numerose ottimizzazioni (ad esempio clonazione di cicli, spiegamento del ciclo e ottimizzazioni delle variabili di induzione) dipendono anche dall'inversione del ciclo per produrre questa forma per facilitare l'analisi.

.NET 10 migliora l'inversione del ciclo passando da un'implementazione di analisi lessicale a un'implementazione di riconoscimento del ciclo basato su grafo. Questa modifica consente di migliorare la precisione considerando tutti i cicli naturali (cicli con un singolo punto di ingresso) e ignorando i falsi positivi considerati in precedenza. Ciò si traduce in un potenziale di ottimizzazione più elevato per i programmi .NET con istruzioni for e while.

Devirtualizzazione del metodo dell'interfaccia array

Una delle aree di interesse per .NET 10 consiste nel ridurre il sovraccarico di astrazione delle funzionalità del linguaggio più diffuse. Nel perseguimento di questo obiettivo, la capacità del JIT di devirtualizzare le chiamate ai metodi si è espansa per coprire i metodi dell'interfaccia array.

Si consideri l'approccio tipico di scorrere un array:

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

Questa forma di codice è facile per il JIT da ottimizzare, principalmente perché non ci sono chiamate virtuali da considerare. Il JIT può invece concentrarsi sulla rimozione dei controlli dei limiti di accesso sull'array e sull'applicazione delle ottimizzazioni del ciclo aggiunte in .NET 9. L'esempio seguente aggiunge alcune chiamate virtuali:

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

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

Il tipo della raccolta sottostante è chiaro e JIT dovrebbe essere in grado di trasformare questo frammento nel primo esempio. Tuttavia, le interfacce di matrice vengono implementate in modo diverso dalle interfacce "normali", in modo che il JIT non sappia come devirtualizzarle. Ciò significa che le chiamate dell'enumeratore foreach nel ciclo rimangono virtuali, bloccando diverse ottimizzazioni, ad esempio l'inlining e l'allocazione dello stack.

A partire da .NET 10, JIT può devirtualizzare e integrare in linea i metodi dell'interfaccia della matrice. Questo è il primo dei primi passaggi per ottenere la parità delle prestazioni tra le implementazioni, come descritto in dettaglio nei piani di de-astrazione .NET 10.

De-astrazione dell'enumerazione di array

Gli sforzi per ridurre il sovraccarico di astrazione nell'iterazione degli array tramite enumeratori hanno migliorato le capacità di inlining, allocazione dello stack e clonazione dei cicli del compilatore JIT. Ad esempio, il sovraccarico dell'enumerazione delle matrici tramite IEnumerable è ridotto e l'analisi dell'escape condizionale consente ora l'allocazione dello stack di enumeratori in determinati scenari.

Layout del codice migliorato

Il compilatore JIT in .NET 10 introduce un nuovo approccio all'organizzazione del codice del metodo in blocchi di base per migliorare le prestazioni di runtime. In precedenza, il JIT utilizzava un attraversamento in postordine inverso (RPO) del grafo del flusso del programma come layout iniziale, seguito da trasformazioni iterative. Sebbene efficace, questo approccio presentava limitazioni nella modellazione dei compromessi tra la riduzione della ramificazione e l'aumento della densità del codice caldo.

In .NET 10, JIT modella il problema di riordinamento dei blocchi come una riduzione del Problema del commesso viaggiatore asimmetrico e implementa l'euristica 3-opt per trovare un attraversamento quasi ottimale. Questa ottimizzazione migliora la densità del percorso critico e riduce le distanze dei rami, ottenendo prestazioni di runtime migliori.

Miglioramenti dell'inlining

Sono stati apportati vari miglioramenti all'inlining in .NET 10.

Il JIT può ora inserire in linea i metodi che sono divenuti idonei per la devirtualizzazione grazie all'inlining precedente. Questo miglioramento consente al JIT di individuare più opportunità di ottimizzazione, ad esempio ulteriori opportunità di inlining e devirtualizzazione.

Alcuni metodi con semantica di gestione delle eccezioni, in particolare i blocchi con try-finally, possono anche essere inseriti in linea.

Per sfruttare meglio la capacità del JIT di allocare alcune matrici, l'euristica dell'inliner è stata modificata per aumentare la redditività dei candidati che potrebbero restituire matrici di piccole dimensioni fisse.

Tipi restituiti

Durante l'inlining, il JIT ora aggiorna il tipo delle variabili temporanee che contengono i valori di ritorno. Se tutti i punti di ritorno nella funzione chiamata restituiscono lo stesso tipo, queste informazioni precise sul tipo vengono utilizzate per devirtualizzare le chiamate successive. Questo miglioramento integra i miglioramenti apportati alla devirtualizzazione tardiva e alla de-astrazione delle matrici.

Dati del profilo

.NET 10 migliora i criteri di inlining di JIT per sfruttare al meglio i dati del profilo. Tra le numerose euristiche, l'inliner del JIT non considera i metodi di dimensioni superiori a una certa soglia per evitare di ingrandire il metodo chiamante. Quando il chiamante dispone di dati del profilo che suggeriscono che un candidato inlining viene eseguito di frequente, l'inliner aumenta la tolleranza di dimensione per il candidato.

Si supponga che il JIT inserisca in linea una funzione chiamata Callee senza dati del profilo in una funzione chiamante Caller con dati del profilo. Questa discrepanza può verificarsi se la funzione chiamata è troppo piccola per essere integrata o se è integrata troppo spesso per avere un numero di chiamate a sufficienza. Se Callee ha i suoi candidati di inlining, il JIT in precedenza non li considerava con il suo limite di dimensioni predefinito a causa della mancanza di dati del profilo di Callee. A questo punto, il JIT si rende conto che Caller ha dati di profilo e allenta la restrizione delle dimensioni (ma, per tenere conto della perdita di precisione, non allo stesso livello quanto Callee avesse dati di profilo).

Analogamente, quando JIT decide che un sito di chiamata non è redditizio per l'inlining, contrassegna il metodo con NoInlining per evitare che i futuri tentativi di inlining lo considerino. Tuttavia, molte euristiche di incorporazione sono sensibili ai dati di profilazione. Ad esempio, il JIT potrebbe decidere che un metodo è troppo grande per valere la pena di essere incorporato in assenza di dati del profilo. Tuttavia, quando il chiamante è sufficientemente caldo, l'JIT potrebbe essere disposto a ridurre la restrizione delle dimensioni e inline alla chiamata. In .NET 10, JIT non contrassegna più le inline non redditizie con NoInlining per evitare di pessimizzare i siti di chiamata con i dati del profilo.

Supporto di AVX10.2

.NET 10 introduce il supporto per advanced vector extensions (AVX) 10.2 per processori basati su x64. I nuovi oggetti intrinseci disponibili nella System.Runtime.Intrinsics.X86.Avx10v2 classe possono essere testati una volta che è disponibile hardware in grado di supportare.

Poiché l'hardware abilitato per AVX10.2 non è ancora disponibile, il supporto JIT per AVX10.2 è attualmente disabilitato per impostazione predefinita.

Allocazione nello stack

L'allocazione dello stack riduce il numero di oggetti che il GC deve tenere traccia e sblocca anche altre ottimizzazioni. Ad esempio, dopo che un oggetto è stato allocato nello stack, il JIT può considerare di sostituirlo interamente con i suoi valori scalari. Per questo motivo, l'allocazione dello stack è fondamentale per ridurre la penalità di astrazione dei tipi di riferimento. .NET 10 aggiunge l'allocazione dello stack per matrici di piccole dimensioni di tipi valoreematrici di tipi di riferimento di piccole dimensioni. Include anche l'analisi di escape per i campi e i delegati degli struct locali. Gli oggetti che non possono sfuggire possono essere allocati sullo stack.

Piccole matrici di tipi di valore

JIT alloca sullo stack piccole matrici a dimensione fissa di tipi di valore che non contengono puntatori GC quando non sopravvivono rispetto al metodo padre. Nell'esempio seguente, JIT riconosce in fase di compilazione una numbers matrice di soli tre interi che non ha una durata superiore a una chiamata a Sume quindi lo alloca nello stack.

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);
}

Piccole matrici di tipi di riferimento

.NET 10 estende i miglioramenti dell'allocazione dello stack .NET 9 a matrici di tipi di riferimento di piccole dimensioni. In precedenza, le matrici di tipi di riferimento venivano sempre allocate nell'heap, anche quando la durata era limitata a un singolo metodo. Ora, JIT può allocare in pila tali matrici quando determina che non hanno una durata superiore al contesto di creazione. Nell'esempio seguente la matrice words viene ora allocata nello stack.

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

Analisi dell'escape

L'analisi di escape determina se un oggetto può sopravvivere al metodo genitore. Gli oggetti "sfuggono" quando vengono assegnati a variabili non locali o passati a funzioni non integrate dal compilatore JIT. Se un oggetto non può eseguire l'escape, può essere allocato nello stack. .NET 10 include l'analisi delle fughe per:

Campi di struttura locali

A partire da .NET 10, JIT considera gli oggetti che i campi struct richiamano, il che consente un maggior numero di allocazioni nello stack e diminuisce il carico dell'heap. Si consideri l'esempio seguente:

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

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

In genere, lo stack JIT alloca matrici di piccole dimensioni fisse che non sfuggono, ad esempio x. L'assegnazione a y.arr non causa a x di sfuggire, perché anche y non sfugge. Tuttavia, l'implementazione precedente dell'analisi di escape di JIT non realizzava un modello per i riferimenti ai campi delle strutture. In .NET 9 l'assembly x64 generato per Main include una chiamata a CORINFO_HELP_NEWARR_1_VC per allocare x nell'heap, indicando che è stato etichettato come in fase di escape.

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

In .NET 10, JIT non contrassegna più gli oggetti a cui fanno riferimento i campi struct locali come di fuga, purché la struct in questione non sfugga. L'assembly ora assomiglia a questo (notate che la chiamata della funzione helper di allocazione dell'heap è scomparsa):

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

Per altre informazioni sui miglioramenti della detrazione in .NET 10, vedere dotnet/runtime#108913.

Delegati

Quando il codice sorgente viene compilato in IL, ogni delegato viene trasformato in una classe di chiusura con un metodo corrispondente alla definizione e ai campi del delegato corrispondenti a tutte le variabili acquisite. In fase di esecuzione viene creato un oggetto di chiusura per istanziare le variabili acquisite, insieme a un oggetto Func per richiamare il delegato. Se l'analisi di escape determina che l'oggetto Func non ha una durata superiore all'ambito corrente, il jit lo alloca nello stack.

Si consideri il metodo seguente Main :

 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;
}

In precedenza, JIT produceva l'assembly x64 abbreviato seguente per Main. Prima di entrare nel ciclo, arr, func e la classe di chiusura per func chiamata Program+<>c__DisplayClass0_0 vengono allocati nell'heap, come indicato dalle CORINFO_HELP_NEW* chiamate.

       ; 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

Ora, poiché func non viene mai fatto riferimento all'esterno dell'ambito di Main, viene allocato anche nello stack:

       ; 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

Si noti che è rimasta una chiamata CORINFO_HELP_NEW*, che rappresenta l'allocazione dell'heap per la chiusura. Il team di runtime prevede di espandere l'analisi di escape per supportare l'allocazione nello stack delle closure in una versione futura.

Miglioramenti del preinizializzatore di tipo NativeAOT

Il preinitializzatore di tipo NativeAOT supporta ora tutte le varianti degli opcodes conv.* e neg. Questo miglioramento consente la preinitializzazione dei metodi che includono operazioni di cast o negazione, ottimizzando ulteriormente le prestazioni di runtime.

Miglioramenti della barriera di scrittura arm64

Il Garbage Collector (GC) di .NET è generazionale, ovvero separa gli oggetti attivi in base all'età per migliorare le prestazioni della raccolta. Il GC raccoglie più spesso le generazioni più giovani nell'assunto che gli oggetti di lunga durata siano meno probabilmente non referenziati (o "morti") in un dato momento. Si supponga tuttavia che un oggetto precedente inizi a fare riferimento a un oggetto giovane; il GC deve sapere che non può raccogliere l'oggetto giovane. Tuttavia, la necessità di scansionare gli oggetti più vecchi per la raccolta di un oggetto giovane annulla i miglioramenti delle prestazioni di un GC generazionale.

Per risolvere questo problema, JIT inserisce barriere di scrittura prima degli aggiornamenti dei riferimenti agli oggetti per mantenere informato il GC. In x64, il runtime può passare dinamicamente tra implementazioni di barriere di scrittura per bilanciare la velocità di scrittura e l'efficienza della raccolta, a seconda della configurazione di GC. In .NET 10 questa funzionalità è disponibile anche in Arm64. In particolare, la nuova implementazione predefinita della barriera di scrittura su Arm64 gestisce più precisamente le aree GC, migliorando così le prestazioni della raccolta a un costo modesto per il throughput della barriera di scrittura. I benchmark mostrano miglioramenti delle pause del GC, passando da 8% a oltre 20% con le nuove impostazioni predefinite del GC.