Comparaison de deux démarrages .NET : une application classique avec un temps de chauffe JIT visible, à côté d'une application Native AOT qui démarre instantanément, avec une icône de coût cloud en baisse
Retour au blog
.NETNative AOTPerformance

Native AOT en .NET : démarrage plus rapide, facture cloud plus légère

Sascha KieferDéveloppement

Native AOT compile une application .NET entièrement en code natif avant même qu'elle ne s'exécute : pas de JIT, pas de temps de chauffe. Depuis le 1er août 2025, AWS Lambda facture la phase d'initialisation des fonctions packagées en zip exactement comme le temps d'exécution normal, transformant un simple problème de latence en poste de coût. Nous examinons ce que fait réellement Native AOT, quand la migration en vaut la peine, où elle est tout simplement impossible, et comment ce démarrage plus rapide se traduit concrètement sur votre facture cloud.

Un processus .NET qui démarre de façon classique effectue un travail conséquent avant de répondre à sa première requête : charger les assemblies, lire les métadonnées, résoudre les types, puis compiler méthode par méthode en code natif au moment de l'exécution (just-in-time, JIT), dès le premier appel. Sur une application longue durée, cela ne se remarque pas. Sur une fonction Lambda qui démarre un nouveau conteneur à chaque invocation, ou un pod Kubernetes qui doit être prêt en quelques secondes sous un pic de charge, c'est exactement ce temps de démarrage qui compte.

Et depuis le 1er août 2025, ce n'est plus seulement un problème de latence. C'est le jour où AWS a changé son modèle de facturation : la phase d'initialisation des fonctions Lambda packagées en zip avec un runtime managé est désormais facturée comme le temps d'exécution normal. Auparavant, cela ne s'appliquait qu'aux runtimes personnalisés, à la Provisioned Concurrency ou au packaging en image de conteneur. Un cold start lent coûte désormais de l'argent au sens propre, pas seulement une attente perçue.

C'est exactement là qu'intervient Native AOT. Voyons ce qu'il fait réellement, quand il est rentable, où il ne fonctionne tout simplement pas, et ce que cela signifie pour votre facture cloud.

Ce que fait réellement Native AOT

Dans le modèle .NET classique, dotnet publish compile votre code en langage intermédiaire (IL). Au démarrage, le runtime charge cet IL, et le compilateur JIT traduit chaque méthode en code machine natif la première fois qu'elle est appelée. C'est ce qui rend les applications .NET aussi flexibles : la réflexion, le chargement dynamique d'assemblies, la génération de code à l'exécution, tout cela fonctionne parce que le runtime sait ce que contient votre code jusqu'au dernier moment.

Native AOT inverse ce principe. Avec <PublishAot>true</PublishAot> dans votre projet et dotnet publish -r <RID>, la publication lance un compilateur IL (ILC) qui traduit l'intégralité de votre application, y compris chaque bibliothèque référencée, en code natif à l'avance. Le résultat est un exécutable unique, spécifique à une plateforme, avec un runtime allégé (incluant le garbage collector) intégré. Aucun JIT ne s'exécute plus au runtime, puisqu'il n'y en a plus.

Pour que cela fonctionne, tout doit être connu de façon statique au moment de la publication. Le code inutilisé est supprimé par trimming, et tout ce qui déciderait à l'exécution quel code existe réellement est exclu. Ce n'est pas un détail d'implémentation, c'est la contrainte centrale autour de laquelle tourne presque tout le reste de cet article.

Plus rapide, mais pas meilleur sur tous les plans

La documentation Microsoft reste volontairement vague sur les chiffres précis, car les gains réels dépendent fortement du workload, mais elle met systématiquement en avant trois effets : taille de déploiement réduite, temps de démarrage plus court, besoin mémoire plus faible. Dans la pratique, des ordres de grandeur comme « environ trois fois plus rapide au démarrage, pour moins de la moitié de la consommation mémoire » circulent largement, et certaines équipes publient des chiffres avant/après concrets, passant par exemple d'environ 70 ms à environ 14 ms pour une API simple. Prenez ces chiffres comme une tendance, pas comme une garantie pour votre cas : ils dépendent fortement de ce que votre application charge et initialise réellement au démarrage.

L'autre face de la médaille compte tout autant : Native AOT optimise le démarrage, pas automatiquement le débit. Le JIT peut optimiser en fonction du comportement réel à l'exécution (compilation par paliers, PGO) ; Native AOT fige le code dès la publication. Un détail concret l'illustre : System.Linq.Expressions s'exécute toujours en mode interprété sous Native AOT, nettement plus lent que le code compilé à l'exécution sous JIT. Pour une fonction éphémère, c'est sans importance. Pour un service longue durée à fort débit, cela peut être exactement l'inverse de ce que vous recherchez.

Quand Native AOT est rentable

  • Fonctions serverless. AWS Lambda (runtime personnalisé exécutant un binaire Native AOT) et Azure Functions (isolated worker avec support AOT) en profitent le plus directement, car chaque cold start y compte individuellement, et depuis août 2025, financièrement aussi.
  • Kubernetes avec autoscaling agressif ou scale-to-zero. KEDA, Knative ou une configuration HPA personnalisée qui démarre des pods à partir de rien bénéficient d'un time-to-ready court, tout comme les fonctions serverless.
  • Outils CLI et sidecars. Un outil en ligne de commande exécuté cent fois par jour pendant une fraction de seconde ressent directement chaque milliseconde de démarrage économisée, sans contexte cloud requis.
  • Déploiements à haute densité. De nombreuses petites instances de service partageant une infrastructure commune, où une empreinte mémoire réduite par instance signifie directement plus d'instances par nœud.
  • Plateformes où le JIT n'est simplement pas autorisé. Sur iOS et tvOS, le système d'exploitation interdit purement et simplement la compilation just-in-time. Là, la compilation ahead-of-time n'est pas une option parmi d'autres, c'est le seul moyen de faire fonctionner une application .NET.

Le calcul de coût : ce que le démarrage plus rapide fait réellement économiser

Détaillons un exemple avec des hypothèses clairement posées, pas des chiffres universels.

Lambda, calculé directement. Prenez une fonction de 512 Mo de mémoire, 500 000 invocations par mois, et un taux de cold start de 30 % (150 000 cold starts), réaliste pour un backend à usage modéré. Si la phase d'initialisation prend en moyenne 600 ms sur un runtime JIT classique contre 50 ms sur un binaire Native AOT, vous économisez 0,5 Go × 0,55 s = 0,275 Go-secondes par cold start. À environ 0,0000167 $ par Go-seconde, cela représente à peine 0,69 $ par mois sur 150 000 cold starts. Rien que sur la réduction de la durée du cold start, c'est une note de bas de page, pas un business case.

Le vrai levier est ailleurs. La Provisioned Concurrency facture une capacité réservée en continu, que la fonction soit invoquée ou non. Si Native AOT rend vos cold starts suffisamment rapides et réguliers pour vous permettre de supprimer entièrement la Provisioned Concurrency, vous éliminez un coût fixe permanent, pas une erreur d'arrondi par invocation. Et comme Lambda facture mémoire × temps, une empreinte mémoire plus faible joue sur chaque invocation, pas seulement les cold starts. Si Native AOT vous permet de faire passer la mémoire configurée de 512 Mo à 256 Mo, ce facteur est divisé par deux sur chaque exécution, pas seulement sur les cold starts occasionnels.

Kubernetes, par densité mémoire. Si un processus d'API web .NET démarré classiquement consomme environ 300 Mo de RSS en régime permanent, et qu'une version Native AOT n'en consomme qu'environ 120 Mo, un nœud disposant de 4 096 Mo de mémoire allouable accueille environ 13 pods dans un cas et 34 dans l'autre. Ce n'est pas un gain de performance abstrait, c'est la différence entre trois nœuds et un seul nœud pour la même charge.

Le revers de la médaille doit figurer dans le calcul. Pour un service longue durée avec peu de redémarrages, une charge stable et un autoscaling qui se déclenche rarement de toute façon, l'avantage au démarrage est pratiquement sans effet. Là, c'est le débit en régime permanent qui compte, et Native AOT est au mieux neutre. La migration n'y est rentable que si la densité mémoire fait, à elle seule, une différence.

Où c'est tout simplement impossible

Certaines limitations ne sont pas des bugs, elles sont la conséquence directe du fait que tout doit être connu de façon statique au moment de la publication :

  • Pas de Assembly.LoadFile / Assembly.LoadFrom. Les architectures à plugins qui chargent des assemblies inconnues à l'exécution ne fonctionnent pas, car le trimming supprime tout ce qui n'est pas atteignable statiquement.
  • Pas de System.Reflection.Emit. La génération de code à l'exécution est exclue. Cela écarte les anciens conteneurs d'injection de dépendances, les frameworks de mocking et les ORM qui génèrent des proxys dynamiques à l'exécution.
  • Pas de C++/CLI, pas de COM intégré (Windows). Les applications d'interop COM classiques restent sur le modèle JIT, sauf à migrer vers des ComWrappers générés par le compilateur.
  • Le support Native AOT d'EF Core est expérimental. Depuis EF Core 9, il existe une tâche MSBuild pour les modèles et requêtes précompilés, mais selon Microsoft, ce n'est « pas encore adapté à un usage en production ». Un service fortement dépendant d'EF Core n'est pas, aujourd'hui, un bon candidat pour Native AOT.
  • De larges portions d'ASP.NET Core manquent. Le tableau ci-dessous montre un extrait de la liste de compatibilité officielle :
FonctionnalitéNative AOT
Minimal APIs✅ (partiel)
gRPC
Authentification JWT
MVC (contrôleurs avec vues)
Blazor Server
Session
Autres authentifications (par ex. cookie, fournisseurs OAuth externes)
SignalR✅ (partiel)

Une application web classique rendue côté serveur, avec vues Razor, sessions et connexion par cookie, n'est donc tout simplement pas, aujourd'hui, une candidate à Native AOT, quel que soit l'attrait du démarrage plus rapide.

  • L'écosystème NuGet est inégal. De nombreuses bibliothèques populaires utilisent la réflexion pour la découverte de types, chargent des dépendances de façon conditionnelle à l'exécution, ou génèrent du code dynamiquement. .NET 10 a introduit l'attribut IsAotCompatible pour marquer explicitement les bibliothèques compatibles, mais loin de toutes le portent encore. Vérifiez vos dépendances avant de planifier une migration, pas après.
  • Fini le « on compile une fois, ça tourne partout ». Chaque plateforme cible nécessite sa propre publication (win-x64, linux-arm64, osx-arm64, …), et un binaire Linux compilé sous Ubuntu 20.04 fonctionne sur 20.04 et les versions ultérieures, mais pas sur des versions antérieures. C'est un vrai saut de complexité dans votre matrice de CI par rapport à un build unique dépendant du framework.

Mise en place : de PublishAot à un artefact exécutable

Ne migrez pas le monolithe d'un coup. Choisissez une seule fonction Lambda ou un seul service minimal-API ou gRPC, et parcourez toute la chaîne, du fichier projet à l'image Docker.

Projet et code

Activez Native AOT dans le projet :

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
  <StripSymbols>true</StripSymbols>
</PropertyGroup>

InvariantGlobalization est optionnel, mais il réduit encore la taille et le temps de démarrage si vous n'avez pas besoin de tri ou de formatage spécifiques à une culture, vérifiez cela sur des données réelles, pas seulement sur des chiffres.

Dans Program.cs, vous avez besoin de CreateSlimBuilder plutôt que CreateBuilder, et d'un contexte JSON généré par le compilateur pour chaque classe transmise via HTTP :

using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});

var app = builder.Build();

app.MapGet("/health", () => Results.Ok(new HealthStatus("ok")));

await app.RunAsync();

record HealthStatus(string Status);

[JsonSerializable(typeof(HealthStatus))]
partial class AppJsonContext : JsonSerializerContext;

La publication cible une plateforme précise, pas « any CPU » :

dotnet publish -c Release -r linux-x64 --self-contained

Lisez chaque avertissement émis par la commande (codes IL2xxx/IL3xxx). Un avertissement ignoré peut devenir une exception à l'exécution, pas un repli avec une fonctionnalité légèrement réduite.

Image Docker

Pour un déploiement de type Kamal, vous avez besoin d'un build multi-étapes : l'étape de build nécessite le conteneur SDK complet avec une chaîne de compilation (clang, zlib1g-dev) ; l'étape finale n'a besoin que de l'exécutable :

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN apt-get update && apt-get install -y clang zlib1g-dev \
    && dotnet publish -c Release -r linux-x64 --self-contained -o /app

FROM mcr.microsoft.com/dotnet/runtime-deps:10.0
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["./MyService"]

L'image finale ne contient plus aucun runtime .NET, seulement le binaire natif et ses dépendances au niveau du système, donc plus légère qu'un déploiement self-contained classique.

Cas particulier : runtime personnalisé AWS Lambda

Pour Lambda, vous avez besoin en plus de Amazon.Lambda.RuntimeSupport et Amazon.Lambda.Core :

using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;

await LambdaBootstrapBuilder.Create(Handler, new SourceGeneratorLambdaJsonSerializer<AppJsonContext>())
    .Build()
    .RunAsync();

static OrderResult Handler(OrderRequest request) => new(request.OrderId, "processed");

Important : pour un runtime personnalisé, AWS s'attend à ce que le binaire natif de votre paquet de déploiement s'appelle exactement bootstrap. En publiant normalement, le fichier porte le nom de votre projet, pas bootstrap, et Lambda ne trouve rien à exécuter. Renommez-le à l'étape de publication, ou définissez <AssemblyName>bootstrap</AssemblyName> dans le projet.

Pièges rencontrés en pratique

  • dotnet build n'affiche presque jamais d'avertissements, dotnet publish si. L'analyse complète de trimming et d'AOT ne s'exécute qu'à la publication. Si votre pipeline de pull request ne lance que dotnet build, vous ne remarquerez une dépendance cassée qu'au déploiement réel, dans le pire des cas en production. Intégrez dotnet publish dans le pipeline de PR, pas seulement dans le job de déploiement.
  • Un JsonSerializerContext oublié ne se voit qu'à l'exécution. Sans contexte généré par le compilateur, System.Text.Json retombe sur la réflexion, qui ne fonctionne pas sous Native AOT. L'erreur n'apparaît pas à la publication, mais sous forme d'exception à la première tentative de sérialisation réelle, typiquement à la première vraie requête.
  • L'auto-enregistrement par réflexion dans l'injection de dépendances renvoie des résultats vides plutôt qu'une erreur. Des bibliothèques comme Scrutor scannent les assemblies à l'exécution pour trouver des types correspondant à un motif d'enregistrement. Sous Native AOT, tout ce qui aurait pu être scanné a déjà été supprimé par le trimming, le résultat est un conteneur de services qui ne trouve silencieusement rien à la résolution plutôt que d'échouer bruyamment au démarrage. Remplacez l'auto-enregistrement par des appels explicites à AddSingleton<TInterface, TImplementation>() avec des types concrets.
  • Un binaire compilé sous Ubuntu ne fonctionne pas sous Alpine. Alpine utilise musl plutôt que glibc comme bibliothèque C. Si vous compilez avec -r linux-musl-x64 sur une image SDK basée sur glibc, le résultat lie quand même l'interpréteur glibc, et le conteneur ne démarre pas. Compilez pour des cibles Alpine avec une image SDK basée sur Alpine (mcr.microsoft.com/dotnet/sdk:10.0-alpine), pas avec -r linux-musl-x64 sur une image Debian.
  • Le binaire du runtime personnalisé ne s'appelle pas bootstrap. Voir plus haut, un détail facile à manquer et une erreur de déploiement totalement bloquante sur AWS Lambda.

Mesurez ensuite dans votre propre environnement, pas sur la base de benchmarks communautaires. Un cold start dans votre fonction Lambda, avec vos dépendances réelles, peut se comporter très différemment de l'exemple hello-world de la documentation.

Conclusion

Native AOT résout un problème très concret : un temps de démarrage et une empreinte mémoire qui se traduisent directement en argent dans les environnements serverless et fortement élastiques, et depuis août 2025, littéralement sur AWS Lambda. L'effet direct de la seule réduction de durée du cold start est souvent plus faible qu'attendu ; le vrai levier est ce que Native AOT vous permet de ne plus payer : Provisioned Concurrency, mémoire surdimensionnée, réplicas de réserve inutilisés.

Dans le même temps, la liste de ce qui ne fonctionne pas est suffisamment longue pour être prise au sérieux : pas de Reflection.Emit, pas de chargement dynamique d'assemblies, pas de MVC avec vues, EF Core encore expérimental, un écosystème NuGet qui rattrape encore son retard. Pour un service léger, basé sur JSON, exécuté dans un environnement serverless ou fortement élastique, Native AOT est aujourd'hui une option mature. Pour une application web classique adossée à EF Core, avec sessions et vues, ce n'est pas encore le cas, et probablement pas avant un certain temps.

Sources

Besoin d'aide ?

Vous vous demandez si Native AOT vaut le coup pour vos workloads .NET, ou vous voulez passer votre facture cloud au crible plus généralement ? Nous analysons avec vous où les cold starts, l'empreinte mémoire et la densité d'instances vous coûtent réellement de l'argent, et où une migration en vaut la peine ou pas. Contactez-nous simplement.

Nous contacter