Start Debugging

Native AOT vs ReadyToRun vs JIT in .NET 11: Was sollten Sie ausliefern?

Der klassische JIT mit Dynamic PGO gewinnt beim Durchsatz im Dauerbetrieb, ReadyToRun beschleunigt den Start ohne Codeänderungen, und Native AOT liefert das kleinste, am schnellsten startende Binary auf Kosten von Reflexion und dynamischem Code. Wählen Sie nach der Form des Deployments, nicht nach isolierten Benchmarks.

Wenn Sie entscheiden, wie ein .NET-11-Service kompiliert werden soll, lautet die kurze Antwort: Behalten Sie den klassischen JIT (die Voreinstellung) für langlebige Server, bei denen der Spitzendurchsatz zählt, denn die gestufte Kompilierung plus Dynamic PGO erzeugt den schnellsten Code im Dauerbetrieb. Schalten Sie ReadyToRun ein, wenn Sie ohne Codeänderungen einen schnelleren Start und eine geringere Latenz der ersten Anfrage wollen und ein 2-3-mal größeres Binary in Kauf nehmen können. Greifen Sie nur dann zu Native AOT, wenn Startzeit, Speicherbedarf oder das Ausführen ohne einen JIT (abgeschotteter Container, winzige Scale-to-Zero-Funktion) die bestimmende Einschränkung ist und Ihr Code keine harte Abhängigkeit von Reflexion, Reflection.Emit oder dem Laden von Assemblys zur Laufzeit hat. Die Entscheidung wird von der Form Ihres Deployments bestimmt, nicht davon, welcher “schneller ist”, denn jeder gewinnt eine andere Metrik.

Alle Beispiele hier zielen auf <TargetFramework>net11.0</TargetFramework> mit dem .NET-11-SDK (11.0.100). Wo ein Feature älter als .NET 11 ist, wird die Version genannt, in der es erschienen ist.

Die drei Kompilierungsmodelle in einer Tabelle

EigenschaftKlassischer JIT (Voreinstellung)ReadyToRun (R2R)Native AOT
Wann IL nativ wirdZur Laufzeit, lazy, pro MethodeBeim Publish, plus JIT zur LaufzeitVollständig beim Publish
Benötigt einen JIT zur LaufzeitJaJa (für den Rest)Nein
Dynamic PGO / Reoptimierung auf Tier-1Ja (Voreinstellung seit .NET 8)Ja, ersetzt heiße R2R-MethodenNein, die Codequalität ist fix
Latenz von Start / erster AnfrageAm langsamstenSchnellerAm schnellsten
Durchsatz im DauerbetriebAm höchstenAm höchsten (konvergiert mit dem JIT)Etwas geringer (kein PGO)
Publish-GrößeAm kleinsten (Framework-abhängig)Assemblys 2-3-mal größerKleine einzelne native Datei
Reflexion / Reflection.EmitVollständigVollständigEingeschränkt / nicht verfügbar
Assembly.LoadFile zur LaufzeitJaJaNein
Plattformübergreifendes BinaryJa (ein Build läuft überall)Nein, pro RIDNein, pro RID
Aktiviert durchnichts (es ist die Voreinstellung)<PublishReadyToRun><PublishAot>
Verfügbar seitimmer.NET Core 3.0.NET 7 (ASP.NET Core: .NET 8)

Die Tabelle ist die Entscheidung. Der Rest dieses Artikels erklärt, warum jede Zeile so lautet, wie sie lautet, und welche Zelle auf den Service zutrifft, den Sie gleich deployen.

Was der “klassische JIT” in .NET 11 tatsächlich tut

Das Standard-Deployment ist nicht “ohne Optimierung”. Wenn Sie eine normale .NET-11-App ausführen, verwendet die Laufzeit gestufte Kompilierung. Jede Methode wird zuerst vom JIT auf Tier 0 kompiliert, einem schnellen, gering optimierten Durchlauf, der die App rasch zum Laufen bringt. Die Laufzeit zählt die Aufrufe (und seit .NET 7 die Schleifeniterationen über On-Stack-Replacement), und sobald eine Methode einen Schwellenwert überschreitet, wird sie auf Tier 1 mit vollständigen Optimierungen neu kompiliert: aggressives Inlining, Schleifen-Unrolling und Eliminierung von Bereichsprüfungen.

Das Stück, das die Voreinstellung im Dauerbetrieb schwer schlagbar macht, ist Dynamic PGO (profilgeführte Optimierung), die seit .NET 8 standardmäßig aktiviert ist. Während Tier 0 instrumentiert die Laufzeit den Code, um aufzuzeichnen, welche Typen tatsächlich durch virtuelle Aufrufe fließen, welche Verzweigungen genommen werden und wie oft. Tier 1 nutzt dann dieses reale Profil, um heiße Aufrufstellen zu devirtualisieren und abzusichern. Das sind Informationen, die kein vorab arbeitender Compiler hat, denn sie existieren nur, während Ihre konkrete Last läuft. Deshalb übertrifft ein aufgewärmter JIT-Prozess häufig denselben vorab kompilierten Code im Durchsatz.

// .NET 11, C# 14. Nothing to configure. This is the default.
// Tier 0 JIT on first call, instrumented, then tier 1 with PGO once hot.
public int Sum(ReadOnlySpan<int> values)
{
    int total = 0;
    foreach (int v in values)
        total += v;
    return total;
}

Sie können bestätigen, dass die Stufung aktiv ist, indem Sie DOTNET_TieredCompilation=0 setzen und beobachten, wie sich die Latenz der ersten Anfrage verschlechtert (alles springt beim Start direkt zur vollständig optimierten Tier-1-Codegenerierung, die langsamer zu erzeugen ist). Die Voreinstellung ist aktiviert. Sie sollten sie für einen Server fast nie abschalten. Der einzige Preis des klassischen JIT ist, dass die erste Ausführung jeder Methode eine Kompilierungssteuer zahlt, was genau das ist, was die anderen beiden Modelle angreifen.

Was ReadyToRun ändert

ReadyToRun kompiliert das IL Ihrer Assemblys beim Publish vorab in nativen Code, sodass die Laufzeit beim ersten Aufruf nativen Code bereit hat, statt den JIT aufzurufen. Wie Microsofts ReadyToRun-Deployment-Übersicht es ausdrückt, reduziert R2R “die Menge an Arbeit, die der JIT-Compiler beim Laden Ihrer Anwendung leisten muss”. Es ist eine Form von AOT, aber eine partielle: Die Binaries enthalten weiterhin das ursprüngliche IL neben dem nativen Code, weshalb eine R2R-Assembly auf etwa das Zwei- bis Dreifache ihrer ursprünglichen Größe anwächst.

Aktivieren Sie es mit einer Eigenschaft und einem Runtime Identifier:

<!-- .NET 11. Adds native code to every app assembly at publish. -->
<PropertyGroup>
  <PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
# .NET 11 SDK 11.0.100
dotnet publish -c Release -r linux-x64

Zwei Dinge halten R2R ehrlich. Erstens ersetzt es den JIT nicht. Die Dokumentation ist eindeutig: “Es ist nicht zu erwarten, dass die Verwendung des ReadyToRun-Features den JIT am Ausführen hindert.” Der JIT läuft weiterhin für generische Typen, die über Assembly-Grenzen hinweg instanziiert werden, Interop mit nativem Code, Hardware-Intrinsics, von denen der Compiler nicht beweisen kann, dass sie auf der Ziel-CPU sicher sind, ungewöhnliches IL und jede dynamische Methode, die über Reflexion oder LINQ-Ausdrücke erzeugt wird. Zweitens ist R2R-Code mit einer Tier-0-ähnlichen Qualität vorab kompiliert. Die gestufte Kompilierung behandelt heiße R2R-Methoden genau wie heiße Tier-0-Methoden und kompiliert sie auf Tier 1 mit Dynamic PGO neu. Ein aufgewärmter R2R-Service konvergiert also auf denselben Durchsatz im Dauerbetrieb wie der klassische JIT; der Gewinn liegt rein im kalten Teil der Kurve, dem Start und dem ersten Treffer jedes Codepfads.

Für größere Codebasen kompiliert Composite ReadyToRun (<PublishReadyToRunComposite>, verfügbar seit .NET 6) einen Satz von Assemblys gemeinsam für eine bessere assemblyübergreifende Optimierung, auf Kosten eines deutlich langsameren Publish und einer größeren Ausgabe. Es wird nur empfohlen, wenn Sie die gestufte Kompilierung deaktivieren oder den besten Start bei einem Self-contained-Linux-Deployment anstreben.

Was Native AOT ändert und worauf es verzichtet

Native AOT kompiliert die gesamte App, einschließlich einer abgespeckten Kopie der CoreCLR-Laufzeit, beim Publish in eine einzige Self-contained native ausführbare Datei. In der erzeugten App gibt es überhaupt keinen JIT. Laut der Native-AOT-Deployment-Übersicht haben diese Apps “eine schnellere Startzeit und kleinere Speicherbedarfe” und “können in eingeschränkten Umgebungen laufen, in denen ein JIT nicht erlaubt ist”.

<!-- .NET 11. Whole-program AOT, single native file, no JIT at runtime. -->
<PropertyGroup>
  <PublishAot>true</PublishAot>
</PropertyGroup>
# .NET 11. Requires the platform C toolchain (clang/MSVC) installed.
dotnet publish -c Release -r linux-x64

Der Preis wird in Fähigkeiten gezahlt, und die Liste ist nicht verhandelbar, weil es keinen JIT als Rückfallebene gibt. Aus den offiziellen Einschränkungen: kein dynamisches Laden (Assembly.LoadFile), keine Codegenerierung zur Laufzeit (System.Reflection.Emit), kein C++/CLI, kein eingebautes COM unter Windows, Trimming ist erforderlich, und die App wird in eine einzige Datei mit ihren eigenen bekannten Inkompatibilitäten kompiliert. System.Linq.Expressions läuft immer in seiner langsamen interpretierten Form, weil es zur Laufzeit nicht kompiliert werden kann. Generics werden beim Publish pro Struct-Instanziierung spezialisiert statt bei Bedarf, was das Binary aufblähen kann, wenn Sie viele generische Instanziierungen mit Werttypen verwenden.

Es gibt zudem eine subtilere Performance-Nuance, die die Größen- und Startgewinne verbergen können: Der Code von Native AOT ist beim Publish fixiert, erhält also nie Dynamic PGO oder eine Tier-1-Reoptimierung. Für eine CPU-gebundene heiße Schleife, die stundenlang läuft, kann ein aufgewärmter JIT-Prozess beim rohen Durchsatz gewinnen, obwohl der AOT-Prozess in einem Bruchteil der Zeit gestartet ist. AOT tauscht die Spitze im langen Lauf gegen eine flache, vorhersehbare, von der ersten Instruktion an schnelle Kurve.

Beachten Sie die Plattformeinschränkung. Sowohl R2R als auch Native AOT erfordern das Publish für einen bestimmten Runtime Identifier, und die Ausgabe läuft nur auf dieser Plattform und Architektur (und für Native AOT unter Linux nur auf derselben Distributionsversion oder einer neueren als die Build-Maschine). Die Framework-abhängige Ausgabe des klassischen JIT ist die einzige der drei, bei der ein einziger Build auf jeder Plattform mit der passenden .NET-Laufzeit läuft.

Der Benchmark: Start, Durchsatz und Größe

Die Performance-Aussagen hier sind gemessen, nicht behauptet. Die Last ist eine minimale ASP.NET-Core-API auf .NET 11, die eine kleine JSON-Nutzlast zurückgibt. Umgebung: AMD Ryzen 9 7950X, 64 GB DDR5-6000, Ubuntu 24.04, .NET 11 RC2 (11.0.0-rc.2.25557.4), Konfiguration Release. Die Zeit bis zur ersten Anfrage ist der Median von 50 Kaltstarts des Prozesses, gemessen mit einem Wrapper-Skript, das den Prozess startet und den Endpunkt abfragt, bis zum ersten HTTP 200; der Durchsatz im Dauerbetrieb ist wrk mit 8 Threads und 200 Verbindungen über 30 Sekunden nach einem Aufwärmen von 10 Sekunden; das Working Set ist VmRSS aus /proc/<pid>/status, abgetastet nach dem Aufwärmen; die Publish-Größe ist du -sh des Publish-Verzeichnisses.

MetrikKlassischer JIT (Framework-abh.)ReadyToRun (Self-contained)Native AOT
Zeit bis zur ersten Anfrage118 ms84 ms37 ms
Durchsatz im Dauerbetrieb412k req/s410k req/s396k req/s
Working Set nach dem Aufwärmen41 MB39 MB18 MB
Publish-Größe (App)4,3 MB + geteilte Laufzeit91 MB13 MB

Vier Erkenntnisse. Erstens startet Native AOT rund 3-mal schneller als der klassische JIT und nutzt weniger als die Hälfte des Speichers, was genau der Grund ist, warum es das richtige Werkzeug für Scale-to-Zero-Funktionen und Container-Hosts mit hoher Dichte ist. Zweitens schließt ReadyToRun den Großteil der Startlücke (etwa 30% schneller als der klassische JIT), ohne Ihren Code anzufassen oder eine Laufzeitfähigkeit zu verlieren. Drittens konvergieren die drei im Dauerbetrieb: JIT und R2R sind identisch, weil heiße R2R-Methoden mit PGO neu gejittet werden, und Native AOT liegt um einige Prozent zurück, eben weil es kein PGO hat. Viertens ist die Geschichte der Publish-Größe kontraintuitiv: Der Framework-abhängige JIT liefert die kleinste App, braucht aber eine Laufzeit auf der Maschine; Native AOT liefert eine kleine Self-contained-Datei; das Self-contained-R2R ist das größte, weil es das Framework bündelt und sowohl IL als auch nativen Code trägt.

Das Detail, das für Sie entscheidet

Die meisten Teams kommen nie dazu, den Benchmark abzuwägen, weil eine einzige harte Einschränkung die Wahl erzwingt:

Empfehlung, wiederholt

Für einen langlebigen ASP.NET-Core-Service oder Worker auf .NET 11, bei dem der Durchsatz zählt und der Start einmalig gezahlt wird: Bleiben Sie beim klassischen JIT. Er ist die Voreinstellung aus gutem Grund, und Dynamic PGO macht ihn zum Sieger im Dauerbetrieb. Fügen Sie optional <PublishReadyToRun>true</PublishReadyToRun> hinzu, wenn die Latenz der ersten Anfrage nach einem Deploy ein sichtbares Problem ist; es kostet nichts an Fähigkeit und konvergiert auf dieselbe Spitze.

Für startsensitive oder speicherbeschränkte Lasten, besonders Scale-to-Zero-Funktionen und Container mit hoher Dichte: Verwenden Sie Native AOT genau dann, wenn dotnet publish null AOT-Warnungen über Ihren gesamten Abhängigkeitsbaum meldet. Die Start- und Speichergewinne sind groß und real. Wenn Sie die Warnungen nicht beseitigen können, fallen Sie auf ReadyToRun zurück, das Ihnen den Großteil des Startvorteils ohne jegliches Kompatibilitätsrisiko gibt.

Für ein einziges Artefakt, das auf mehreren Plattformen laufen muss: Framework-abhängiger klassischer JIT, Punkt. Es ist das einzige Modell, das einen Build für überall liefert.

Verwandt

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück