Qu'est-ce que .NET Native AOT ? - Les différences avec JIT et le trimming

· Mis à jour le: · · C#, .NET, Native AOT, Publication, Conception

Dans Comment transformer C# en DLL native avec Native AOT - L’appeler depuis C/C++ via UnmanagedCallersOnly, nous avions déjà abordé l’utilisation de Native AOT pour appeler du C# depuis C/C++. Honnêtement, il aurait été plus judicieux de commencer par ce qu’est réellement Native AOT. L’ordre s’est un peu inversé.

Les discussions sur Native AOT sont réputées pour mélanger les termes dès le départ.

  • S’agit-il de se débarrasser du JIT ?
  • En quoi diffère-t-il de self-contained et de single-file ?
  • Appartient-il à la même famille que ReadyToRun ?
  • Que se passe-t-il vraiment quand un déluge d’avertissements de trimming apparaît ?
  • WPF / WinForms / ASP.NET Core peuvent-ils tous l’utiliser avec le même niveau de confort ?

Quand tout cela se mélange, Native AOT finit par ressembler soit à « une magie qui rend tout plus rapide », soit, à l’inverse, à « une chose effrayante pleine de restrictions ». Les deux visions sont un peu trop simplistes.

Dans cet article, en se plaçant dans le contexte pratique actuel de .NET 8 et versions ultérieures, nous clarifions d’abord ces quatre points.

  • Ce qu’est réellement Native AOT
  • Ce que l’on y gagne, et où cela devient difficile
  • En quoi il diffère de ReadyToRun et du trimming
  • Quels types d’applications se prêtent à une première approche en douceur

Table des matières

  1. La conclusion d’abord (en une phrase)
  2. Les tableaux à regarder en premier
    • 2.1. Le vocabulaire autour de Native AOT
    • 2.2. Différences entre JIT, ReadyToRun et Native AOT
  3. La vue d’ensemble de Native AOT (schéma)
  4. Ce que Native AOT apporte
    • 4.1. Le démarrage a tendance à s’alléger
    • 4.2. Pas besoin de présupposer un runtime préinstallé
    • 4.3. Un bon choix pour les environnements d’exécution restreints
  5. Là où Native AOT devient difficile
    • 5.1. Réflexion et génération de code dynamique
    • 5.2. Il faut raisonner en priorité en termes de trimming
    • 5.3. Une publication par plateforme
    • 5.4. Les contextes Windows desktop / COM appellent à une vraie prudence
  6. Étapes minimales
    • 6.1. Le csproj
    • 6.2. La publication
    • 6.3. Comment écrire le JSON
  7. Cas où cela convient
  8. Cas où cela ne convient pas
  9. Pièges courants
  10. Résumé
  11. Références

1. La conclusion d’abord (en une phrase)

  • Native AOT est un mode de publication qui compile une application .NET en code natif de manière anticipée, au moment du publish, pour la distribuer sous cette forme.
  • N’utilisant pas de JIT à l’exécution, le temps de démarrage et l’empreinte mémoire ont tendance à s’améliorer, et il devient facile de déployer sur des environnements où le runtime .NET n’est pas installé.
  • En contrepartie, il s’accommode mal de la réflexion libre, de la génération de code dynamique, du COM intégré (built-in COM) et des bibliothèques qui ne prennent pas en charge le trimming.
  • Autrement dit, plus qu’une formule magique de rapidité, c’est un mode de publication qui, pour les besoins du démarrage, de la distribution et de l’environnement d’exécution, délaisse un peu le monde dynamique pour se rapprocher du monde statique.

Native AOT est « un mécanisme pour distribuer .NET à la manière d’une application native » — pas une simple case à cocher pour accélérer la compilation.

2. Les tableaux à regarder en premier

2.1. Le vocabulaire autour de Native AOT

Séparer ces termes dès le départ facilite tout ce qui suit.

Terme Ce qu’il fait Relation avec Native AOT
JIT Génère du code natif à partir de l’IL à l’exécution Native AOT effectue ce travail par anticipation
self-contained Distribue avec l’application l’ensemble .NET nécessaire à l’exécution Native AOT s’inscrit dans cette même logique
single-file Regroupe la distribution en un seul fichier Distinct de l’essence de Native AOT, mais le résultat final se ressemble souvent
trimming Supprime le code inutilisé Pratiquement un prérequis sous Native AOT
ReadyToRun Conserve l’IL tout en anticipant une partie du travail du JIT Un nom similaire, mais une chose réellement différente
source generator Déplace le comportement dynamique de l’exécution vers la génération de code au moment de la compilation S’associe bien à Native AOT

Ce qui prête à confusion, c’est que Native AOT n’est pas tant une fonctionnalité isolée qu’un mode de publication qui fonctionne main dans la main avec self-contained, le trimming, la génération de code source et la publication figée sur un RID.

2.2. Différences entre JIT, ReadyToRun et Native AOT

Là aussi, un seul tableau permet d’aller plus vite.

Aspect Exécution JIT classique ReadyToRun Native AOT
JIT à l’exécution Utilisé Encore utilisé dans certains cas Non utilisé
Contenu de la distribution Essentiellement de l’IL IL + code pré-généré Essentiellement un exécutable natif
Démarrage Référence Facile à améliorer Très facile à améliorer
Compatibilité La plus large Large Fortement contrainte
Fonctionnalités dynamiques Faciles à utiliser Globalement faciles à utiliser Fortement restreintes
Objectif privilégié Le développement .NET classique en général Un premier pas vers un meilleur démarrage Rechercher activement démarrage, distribution et environnements restreints

Si ReadyToRun va dans le sens de « faciliter un peu le travail du JIT », Native AOT va dans le sens de « ne plus présupposer de JIT à l’exécution du tout ». Les mêmes lettres A-O-T apparaissent, mais le tempérament est très différent.

3. La vue d’ensemble de Native AOT (schéma)

Esquissé grossièrement, Native AOT ressemble à ceci.

Exécution normaledotnet publish + PublishAotCode source C# / .NETAssemblies ILJIT à l'exécutionExécution de l'applicationAnalyse AOT / trimSuppression du code inutiliséGénération de code natifExécutable spécifique au RID

En temps normal, .NET produit d’abord de l’IL, puis compile en JIT uniquement ce qui est nécessaire à l’exécution. Native AOT avance dans le temps une grande partie de cette étape ultérieure, en la déplaçant au moment du publish.

Le point crucial ici est qu’au moment du publish, l’outillage « doit connaître pratiquement tout le code qui sera nécessaire à l’exécution ». C’est là que l’atmosphère change.

  • Découvrir des types à l’exécution
  • Générer du code à l’exécution
  • Charger des assemblies à l’exécution
  • Reporter une résolution à l’exécution en se disant « ça finira bien par s’arranger »

Ce type d’écriture cesse brusquement de bien s’entendre avec Native AOT.

4. Ce que Native AOT apporte

4.1. Le démarrage a tendance à s’alléger

L’effet le plus visible de Native AOT est, sans surprise, le démarrage.

  • Les outils CLI
  • Les processus de courte durée
  • Les démarrages de type serverless
  • Le démarrage et le renouvellement des conteneurs
  • Les outils de surveillance et les petits processus résidents

Dans ces scénarios, le coût du JIT est facile à observer, et comme Native AOT l’anticipe, les tout premiers instants deviennent plus légers.

L’empreinte mémoire a également tendance à s’améliorer, ce qui aide quand on veut faire tenir davantage d’instances sur le même matériel. Côté cloud en particulier, où de nombreuses copies du même processus démarrent en masse, cette différence s’accumule régulièrement.

4.2. Pas besoin de présupposer un runtime préinstallé

Une application publiée avec Native AOT est facile à exécuter sur des machines où le runtime .NET n’est pas installé.

C’est un avantage discret mais important.

  • On ne veut pas dire à la cible de déploiement « installez d’abord le .NET 9 Runtime »
  • On veut des images de conteneur plus légères
  • On veut déposer un petit outil unique quelque part et le faire fonctionner
  • On ne veut pas — ou on n’a pas le droit — d’autoriser le JIT dans l’environnement d’exécution

Dans ces situations, le simple fait de supprimer le prérequis « il faut fournir le runtime séparément » rend tout beaucoup plus serein.

Notons que « pas besoin de runtime », ici, signifie que la cible de déploiement n’a pas besoin d’une installation .NET séparée. Cela ne signifie pas que les parties équivalentes au runtime disparaissent entièrement de l’application.

4.3. Un bon choix pour les environnements d’exécution restreints

Comme Native AOT n’utilise pas de JIT à l’exécution, il est plus facile à faire fonctionner dans les environnements où le JIT n’est pas autorisé.

Cet avantage se ressent davantage côté cloud, conteneurs et mobile que côté desktop. Cela dit, même dans un contexte de développement Windows, cela reste un vrai atout au sens où l’on « réduit les prérequis superflus sur la cible de déploiement ».

5. Là où Native AOT devient difficile

5.1. Réflexion et génération de code dynamique

C’est ici que se trouve le cœur des contraintes de Native AOT.

  • Le chargement dynamique, comme Assembly.LoadFile
  • La génération de code à l’exécution, comme System.Reflection.Emit
  • La réflexion qui parcourt les types sans restriction à l’exécution
  • Le code qui compose librement des génériques à l’exécution

Tout cela rend difficile de fixer à l’avance, au moment du publish, le code nécessaire, ce qui en fait un terreau fertile pour les avertissements AOT.

Bien sûr, ce n’est pas aussi simple que « une seule ligne de réflexion et c’est fichu ». Mais la tendance ne se dément jamais : plus la conception penche vers « on regarde à l’exécution et on décide », plus cela devient difficile.

Les noms d’avertissements que l’on rencontre le plus souvent appartiennent à la famille RequiresDynamicCode. Ils signifient « cet appel risque de casser sous AOT », donc mieux vaut ne pas les supprimer à la légère.

Quand on fait du Native AOT, un modèle mental utile consiste à réduire « l’intelligence à l’exécution » et à accroître « l’explicite au moment de la compilation ».

5.2. Il faut raisonner en priorité en termes de trimming

Native AOT est étroitement lié au trimming. Ce qu’il est facile de manquer ici, c’est que ce n’est pas seulement votre propre code qui compte, mais aussi la façon dont vos bibliothèques dépendantes sont écrites.

Les suspects habituels sont les suivants.

  • Les sérialiseurs basés sur la réflexion
  • Les configurations de DI / plugins qui rassemblent les types par balayage à l’exécution
  • Les mécanismes qui recherchent un type par son nom sous forme de chaîne et l’instancient
  • Les bibliothèques qui s’appuient sur des proxys dynamiques ou de la génération d’IL

Si des avertissements apparaissent ici et que l’on se contente de « le publish est passé, donc tout va bien », la facture arrive plus tard, et elle est salée. Sous Native AOT, les avertissements méritent en général d’être lus sérieusement.

5.3. Une publication par plateforme

Native AOT publie en se figeant sur un RID (Runtime Identifier). Autrement dit, ce n’est pas un monde où quelque chose construit pour win-x64 s’exécute tel quel sur linux-x64.

  • Windows x64
  • Windows Arm64
  • Linux x64
  • Linux Arm64
  • macOS Arm64

On produit une sortie de publish par cible, comme cela.

Cela donne une sensation beaucoup plus « application native » qu’un .NET classique dépendant du framework (framework-dependent).

5.4. Les contextes Windows desktop / COM appellent à une vraie prudence

Dans le contexte de travail de KomuraSoft, ce point compte particulièrement.

Sous Windows, Native AOT n’a pas de COM intégré (built-in COM). De plus, WPF s’accommode mal du trimming, et WinForms dépend fortement du marshalling COM intégré — donc, au moins pour l’instant, ni l’un ni l’autre ne devrait figurer en tête de votre liste de « premiers candidats à Native AOT ».

En bref, voici ce qui a tendance à jeter un froid :

  • Convertir directement le corps d’une application WPF / WinForms existante en Native AOT
  • Apporter l’interopérabilité COM en gardant ses habitudes intactes

À l’inverse, voici des points d’entrée bien plus naturels :

  • Les applications console
  • Les workers
  • Les petites API Web
  • Les composants d’interopérabilité native que l’on peut ramener à une frontière de fonctions C

Si COM est nécessaire, il y a des cas où rester sur le JIT, ou repenser la conception autour de ComWrappers / du COM généré par source generator, est la voie la plus raisonnable.

6. Étapes minimales

6.1. Le csproj

Commencez par ajouter PublishAot au fichier projet.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

net8.0 suffit pour les exemples. Le raisonnement reste essentiellement le même sous .NET 9 / 10.

Ce qui compte, ce n’est pas de l’ajouter temporairement sur la ligne de commande dotnet publish, mais de le laisser en permanence dans le projet, pour voir l’analyse de build / publish au quotidien.

Notez que l’ajout de <PublishAot>true</PublishAot> ne transforme pas soudainement vos exécutions locales habituelles en Native AOT. Au quotidien, dotnet run et l’exécution normale restent sur le JIT ; la véritable compilation Native AOT a lieu au moment du publish.

6.2. La publication

Pour Windows x64, par exemple :

dotnet publish -c Release -r win-x64

Pour Linux x64 :

dotnet publish -c Release -r linux-x64

La sortie est figée sur le RID. L’état d’esprit passe de « un seul DLL .NET qui fonctionne partout » à « un exécutable construit pour tel OS / telle architecture ».

Si vous abordez le sujet côté API Web, partir du modèle Native AOT est le moyen le plus simple d’entrer dans le sujet.

dotnet new webapiaot -o MyFirstAotWebApi

Pour un worker, voici la commande :

dotnet new worker -o WorkerWithAot --aot

6.3. Comment écrire le JSON

Un point de friction discret mais fréquent sous Native AOT est le JSON. Utilisé avec les habitudes courantes, System.Text.Json a tendance à pencher vers la réflexion ; il est donc plus serein de s’appuyer sur la génération de source (source generation).

using System.Text.Json;
using System.Text.Json.Serialization;

[JsonSerializable(typeof(AppConfig))]
internal partial class AppJsonContext : JsonSerializerContext
{
}

public sealed class AppConfig
{
    public string? Name { get; init; }
    public int RetryCount { get; init; }
}

var config = new AppConfig
{
    Name = "sample",
    RetryCount = 3
};

string json = JsonSerializer.Serialize(config, AppJsonContext.Default.AppConfig);

En pratique, la règle empirique qui se trompe rarement n’est pas tant « rendre compatible avec Native AOT » que « empêcher le runtime de partir à la chasse aux types ».

7. Cas où cela convient

Native AOT a tendance à bien s’imbriquer dans des cas comme ceux-ci.

  • Les outils CLI où le démarrage est la vedette
  • Les petites API déployées en masse dans des conteneurs
  • Les workers / services d’arrière-plan
  • Les processus serverless ou de courte durée
  • Les petits composants .NET insérés dans une application native
  • Les situations où l’on ne veut pas exiger d’installation préalable du runtime .NET

Ce que ces cas ont en commun, c’est que les frontières sont relativement claires et qu’il est facile de réduire les mécanismes dynamiques.

8. Cas où cela ne convient pas

À l’inverse, il existe des cas clairs où Native AOT ne devrait pas être votre terrain de jeu principal dès le départ.

  • Le corps d’une grande application WPF / WinForms existante
  • Les architectures reposant sur l’interopérabilité COM intégrée
  • Les applications où le chargement de plugins à l’exécution est central
  • Les architectures fortement dépendantes de frameworks qui découvrent les types par réflexion
  • Les bibliothèques qui utilisent System.Reflection.Emit ou des proxys dynamiques comme une évidence
  • Les conceptions faisant intervenir C++/CLI

Pour ces cas, un .NET classique reposant sur le JIT, ou ReadyToRun, ou une refonte des frontières de conception, est la voie la plus raisonnable.

9. Pièges courants

Enfin, voici les points sur lesquels on trébuche facilement lors d’un premier essai de Native AOT.

  • Prendre les avertissements de publish à la légère
    • Comme mentionné plus haut, les avertissements de Native AOT méritent d’être lus sérieusement.
  • Le build passe, mais le publish casse
    • Au moment du publish, l’analyse s’exécute réellement à travers vos dépendances aussi, et certaines choses ne deviennent visibles qu’à ce moment-là.
  • Traiter ReadyToRun et Native AOT avec le même état d’esprit
    • Les mots se ressemblent, mais la force des contraintes est très différente.
  • Commencer directement par le corps d’une application desktop
    • Une application console / un worker / une petite API sont des points de départ bien plus sereins.
  • Écrire le JSON ou le binding de configuration avec ses habitudes courantes
    • Le code qui présuppose la réflexion revient vous mordre plus tard.
  • Distribuer comme si c’était indépendant de la plateforme
    • Les sorties de Native AOT sont figées sur le RID.
  • Croire que « Native AOT = tout devient plus rapide »
    • Les vedettes sont le démarrage, la distribution et l’environnement d’exécution. Passer à côté de cela fausse les attentes.

Sous Native AOT, dotnet publish fait davantage office d’arbitre que dotnet build. Commencer à l’exécuter tôt rend la deuxième moitié du projet nettement moins pénible.

10. Résumé

En une phrase, Native AOT est un mécanisme qui fait passer une application .NET d’un modèle d’exécution dynamique à un modèle de distribution que l’on peut fixer de manière statique.

Les points à garder en tête se résument à ces cinq éléments.

  1. Native AOT compile en code natif de manière anticipée, au moment du publish
  2. Il donne d’excellents résultats pour les questions de démarrage, de mémoire et de distribution
  3. En contrepartie, il est exigeant envers la réflexion, la génération de code dynamique, le COM intégré et le code incompatible avec le trimming
  4. Comme première cible, une application console / un worker / une petite API sont plus sereins que le corps d’une application desktop
  5. Il est important de vérifier tôt et souvent via le publish, en éliminant les avertissements au fur et à mesure

Native AOT n’est pas un interrupteur standard à activer sur toutes les applications .NET. Mais là où le démarrage compte, où l’on veut une distribution plus légère et où l’on veut réduire les prérequis liés à l’environnement d’exécution, c’est une arme réellement puissante.

À l’inverse, dans l’univers dense de WPF / WinForms / COM, le .NET classique reste souvent le choix le plus raisonnable. Une fois que l’on sait distinguer ces cas, Native AOT cesse d’être « une nouvelle fonctionnalité difficile » pour devenir une option dont la place est clairement définie.

11. Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Qu'est-ce que Native AOT ?
C'est un mode de publication qui compile une application .NET en code natif de manière anticipée (ahead-of-time), au moment du publish, pour la distribuer sous cette forme. N'utilisant pas de JIT à l'exécution, le temps de démarrage et l'empreinte mémoire ont tendance à s'améliorer, et l'application devient plus facile à déployer sur des environnements où le runtime .NET n'est pas installé. En contrepartie, elle s'accommode mal de la réflexion libre, de la génération de code dynamique, du COM intégré (built-in COM) et des bibliothèques non compatibles avec le trimming. Plus qu'une formule magique qui accélère tout, c'est un mode de publication qui, pour les besoins du démarrage, de la distribution et de l'environnement d'exécution, délaisse un peu le monde dynamique au profit du monde statique.
Quelle est la différence entre Native AOT et ReadyToRun ?
ReadyToRun conserve l'IL tout en anticipant une partie du travail du JIT ; il continue donc d'utiliser le JIT à l'exécution dans certains cas. Sa compatibilité reste large et les fonctionnalités dynamiques restent globalement faciles à utiliser. Native AOT, à l'inverse, ne repose pas du tout sur un JIT à l'exécution : la distribution devient essentiellement un exécutable natif, le démarrage s'améliore nettement plus facilement, mais en contrepartie les contraintes sont beaucoup plus fortes. Bien que les deux portent le même mot « AOT », ReadyToRun va dans le sens de « faciliter un peu le travail du JIT » alors que Native AOT va dans le sens de « ne plus présupposer de JIT » : l'écart de tempérament est important.
Peut-on utiliser Native AOT avec une application WPF ou WinForms ?
Pour l'instant, mieux vaut rester très prudent. Sous Windows, Native AOT n'a pas de COM intégré (built-in COM), WPF s'accommode mal du trimming, et WinForms dépend fortement du marshalling COM intégré : ni l'un ni l'autre ne constitue donc un bon premier candidat pour Native AOT. Si COM est nécessaire, il peut être plus judicieux de rester sur le JIT, ou de repenser la conception autour de ComWrappers / du COM généré par source generator. Comme point d'entrée, une application console, un worker ou une petite API Web sont des choix beaucoup plus naturels.
À quel type d'application Native AOT convient-il ?
Native AOT convient bien aux outils CLI où le démarrage est central, aux petites API déployées en masse dans des conteneurs, aux workers et services d'arrière-plan, aux processus serverless ou de courte durée, aux petits composants .NET insérés dans une application native, ainsi qu'aux situations où l'on ne veut pas exiger d'installation préalable du runtime .NET sur l'environnement d'exécution. Ce qu'ont en commun ces cas, c'est que les frontières sont relativement claires et qu'il est facile de réduire les mécanismes dynamiques. À l'inverse, il ne convient pas aux applications où le chargement de plugins à l'exécution est central, ni aux architectures fortement dépendantes de frameworks qui découvrent les types par réflexion.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog