.NETPerformanceProfilingBackendOptimization

Une méthode concrète pour diagnostiquer une application .NET lente, identifier le coût dominant et vérifier chaque optimisation avec des mesures fiables.

Yva Hajatiana
11 mars 2026
9 min de lecture
Partager :X / TwitterLinkedIn
Composition éditoriale sur la précision et la fiabilité logicielles

Performance .NET : mesurer, profiler puis optimiser

Une application .NET « lente » ne décrit pas encore un problème exploitable. Est-ce la médiane ou le 99e percentile qui se dégrade ? Le temps est-il consommé par le CPU, la base de données, un service externe, le garbage collector ou une file d'attente ? Le comportement apparaît-il à froid, sous charge ou après plusieurs heures ?

Optimiser avant d'avoir répondu à ces questions produit surtout des changements difficiles à justifier. Une démarche fiable transforme d'abord une impression en mesure, puis une mesure en hypothèse vérifiable.

Commencer par un objectif observable

Une optimisation doit viser un comportement utilisateur ou opérationnel. Pour une API, le point de départ peut être :

  • un percentile de latence, par exemple p95 < 300 ms ;
  • un débit minimal à charge constante ;
  • un taux d'erreur maximal ;
  • une limite de CPU ou de mémoire par instance ;
  • un temps de traitement maximal pour un job ;
  • un budget de coût par requête.

La moyenne ne suffit pas. Une API qui répond en 40 ms la plupart du temps et en 4 secondes pour 5 % des appels peut afficher une moyenne acceptable tout en restant pénible pour les utilisateurs.

Fixez aussi le scénario : volume de données, concurrence, environnement, version du runtime et dépendances utilisées. Une mesure effectuée en Debug sur une base vide ne prédit pas le comportement d'un service publié en Release avec plusieurs millions de lignes.

Étape 1 : reproduire le problème

Avant de profiler, construisez le scénario le plus petit qui reproduit la dégradation :

  1. lancez l'application avec une configuration proche de la production ;
  2. préparez un jeu de données représentatif ;
  3. appliquez une charge stable ;
  4. laissez passer le démarrage, le JIT et le remplissage des caches ;
  5. conservez une mesure de référence avant toute modification.

Le test doit rester reproductible. Si la latence varie fortement entre deux exécutions identiques, cherchez d'abord la source de cette variance : dépendance distante, contention, collecte du GC, limitation de ressources ou bruit de l'environnement.

Étape 2 : localiser le coût avant de lire le code

Une trace distribuée répond souvent à la première question : où passe le temps d'une requête ? Elle distingue le traitement local des appels SQL, HTTP, cache ou messagerie. Sa mise en place avec OpenTelemetry et le tableau de bord Aspire est détaillée dans observer les LLM .NET avec OpenTelemetry et Aspire ; la même instrumentation vaut pour n'importe quelle API.

Si 850 ms sur 900 ms sont consommées par une requête SQL, réécrire une boucle C# ne changera rien. À l'inverse, si les dépendances répondent vite mais que le processus utilise un cœur complet, un profil CPU devient pertinent.

Observez au minimum :

  • durée totale et percentiles ;
  • spans des dépendances externes ;
  • CPU du processus ;
  • taille et croissance du tas managé ;
  • fréquence et durée des collectes GC ;
  • taux d'exceptions ;
  • nombre de threads et longueur de la file du ThreadPool.

L'outil dotnet-counters fournit une première vue sans modifier l'application :

dotnet tool update --global dotnet-counters
dotnet-counters monitor --process-id <PID>

Les compteurs indiquent qu'un phénomène existe ; ils n'identifient pas toujours la ligne responsable. Utilisez-les pour orienter l'enquête, pas pour conclure trop vite.

Étape 3 : choisir l'outil adapté au symptôme

SymptômePremier outilQuestion traitée
CPU élevédotnet-counters, puis dotnet-tracequelles piles sont exécutées le plus souvent ?
mémoire qui augmentecompteurs GC, dotnet-gcdump ou dotnet-dumpquels objets restent vivants et pourquoi ?
latence irrégulièretraces distribuées et métriquesquelle dépendance ou attente explique les pics ?
threads bloquéscompteurs ThreadPool, trace ou dumple code bloque-t-il des opérations asynchrones ?
requêtes lentestélémétrie SQL et plan d'exécutionle coût vient-il du nombre ou de la forme des requêtes ?

Pour collecter une trace .NET :

dotnet tool update --global dotnet-trace
dotnet-trace collect --process-id <PID> \
  --profile dotnet-sampled-thread-time,dotnet-common

La trace doit couvrir le scénario problématique, mais rester assez courte pour être analysable. Une capture énorme dilue les piles utiles et augmente le coût de collecte.

Les accès aux données dominent souvent la latence

Dans une API métier, les gains les plus importants viennent fréquemment de la base : suppression d'un N+1, index adapté, projection plus petite ou réduction des allers-retours.

Une lecture qui retourne des DTO n'a pas besoin de charger un graphe d'entités suivi par EF Core :

public Task<OrderSummary?> GetOrderAsync(
    Guid orderId,
    CancellationToken cancellationToken) =>
    _dbContext.Orders
        .AsNoTracking()
        .Where(order => order.Id == orderId)
        .Select(order => new OrderSummary(
            order.Id,
            order.Number,
            order.Total,
            order.Items.Count))
        .SingleOrDefaultAsync(cancellationToken);

Cette projection exprime les colonnes nécessaires et évite le suivi d'entités pour une lecture seule. Elle ne garantit toutefois pas une requête rapide : examinez le SQL produit, le plan d'exécution, les index et la cardinalité réelle.

Les techniques côté EF Core (projections, requêtes compilées, chargement des relations) sont approfondies dans Entity Framework Core : contrôler ses données.

Le cache ne doit venir qu'après cette analyse. Mettre en cache une requête inutilement coûteuse masque parfois le problème tout en ajoutant invalidation, mémoire et cohérence distribuée.

Éviter de bloquer le pipeline asynchrone

Dans ASP.NET Core, appeler .Result, .Wait() ou exécuter un travail long et bloquant dans le chemin d'une requête peut provoquer une pénurie de threads sous charge.

La chaîne doit rester asynchrone jusqu'aux entrées-sorties :

app.MapGet("/orders/{id:guid}", async (
    Guid id,
    IOrderQueries queries,
    CancellationToken cancellationToken) =>
{
    var order = await queries.GetOrderAsync(id, cancellationToken);
    return order is null ? Results.NotFound() : Results.Ok(order);
});

L'asynchronisme ne rend pas un calcul CPU plus rapide. Un traitement lourd doit être borné, déplacé hors de la requête si sa durée est importante, ou exécuté sur une architecture prévue pour ce travail. Pour paralléliser des appels indépendants sans saturer une dépendance, voir Task.WhenAny ou WhenEach : bien choisir.

Comprendre allocations et garbage collection

Réduire les allocations peut améliorer un chemin très fréquent, mais uniquement si les profils montrent une pression mémoire significative.

Cherchez notamment :

  • de gros tableaux ou chaînes recréés à chaque requête ;
  • des matérialisations multiples avec ToList() ;
  • une sérialisation répétée du même contenu ;
  • des objets conservés par un cache sans limite ;
  • des événements ou callbacks jamais désabonnés ;
  • des buffers placés fréquemment sur le Large Object Heap.

Une mémoire élevée n'est pas automatiquement une fuite. Le runtime peut conserver de la mémoire pour la réutiliser. Une fuite se caractérise plutôt par des objets qui restent accessibles alors qu'ils ne servent plus. Comparez plusieurs captures et étudiez les chemins de rétention avant de modifier le code.

Instrumenter les mesures métier importantes

Les métriques runtime ne savent pas qu'une file de commandes attend depuis dix minutes. Ajoutez des métriques applicatives à faible cardinalité :

public sealed class OrderMetrics
{
    public const string MeterName = "YvaDev.Orders";

    private readonly Histogram<double> _processingDuration;

    public OrderMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create(MeterName);

        _processingDuration = meter.CreateHistogram<double>(
            "orders.processing.duration",
            unit: "ms");
    }

    public void RecordProcessingDuration(TimeSpan duration) =>
        _processingDuration.Record(duration.TotalMilliseconds);
}

Conservez un seul Meter et crée les instruments une fois. Évitez les dimensions contenant un identifiant client, une URL brute ou une valeur presque unique : cette forte cardinalité augmente rapidement le coût du système de métriques.

Microbenchmark ou test de charge ?

Un microbenchmark mesure une fonction isolée : parseur, algorithme, allocation ou sérialisation. Il est utile lorsque le profil a déjà identifié ce code comme chaud. BenchmarkDotNet gère le préchauffage, les itérations et la statistique à votre place :

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<SlugBenchmarks>();

[MemoryDiagnoser]
public class SlugBenchmarks
{
    private const string Title = "Performance .NET : mesurer, profiler puis optimiser";

    [Benchmark(Baseline = true)]
    public string WithRegex() =>
        System.Text.RegularExpressions.Regex.Replace(Title.ToLowerInvariant(), "[^a-z0-9]+", "-");

    [Benchmark]
    public string WithStringBuilder()
    {
        var builder = new System.Text.StringBuilder(Title.Length);
        foreach (char c in Title.ToLowerInvariant())
        {
            builder.Append(char.IsAsciiLetterOrDigit(c) ? c : '-');
        }
        return builder.ToString();
    }
}

Exécutez-le en Release (dotnet run -c Release) : [MemoryDiagnoser] ajoute les allocations par opération, souvent plus parlantes que le temps brut. Les deux méthodes ne produisent pas exactement la même chaîne (tirets consécutifs) : un benchmark ne vaut que si les variantes comparées sont fonctionnellement équivalentes, vérifiez-le par un test.

Un test de charge mesure le comportement du système : concurrence, dépendances, files, pools de connexions et percentiles. Il répond mieux aux problèmes d'API.

Les deux approches ne sont pas interchangeables. Gagner 20 % sur une méthode qui représente 1 % de la durée totale ne réduit la latence que de façon marginale.

Vérifier le gain et son coût

Après chaque changement :

  1. rejouez exactement le scénario de référence ;
  2. comparez plusieurs exécutions, pas un résultat isolé ;
  3. vérifiez les percentiles, le débit, les erreurs, le CPU et la mémoire ;
  4. contrôlez que le comportement fonctionnel reste correct ;
  5. documentez le compromis introduit.

Une optimisation peut réduire la latence mais augmenter la mémoire, la complexité ou le coût d'infrastructure. Elle n'est valable que si ce compromis correspond aux contraintes du produit.

Conclusion

Optimiser une application .NET est une enquête : définir le symptôme, reproduire la charge, localiser le coût, choisir l'outil adapté et vérifier le résultat.

Commencez par les frontières les plus coûteuses — base de données, réseau, files et sérialisation — avant les détails syntaxiques. Utilisez les compteurs pour orienter le diagnostic, les traces et dumps pour expliquer le phénomène, puis protégez les gains importants avec des tests reproductibles.

La documentation Microsoft fournit un point d'entrée sur les outils de diagnostic .NET, ainsi que les références de dotnet-trace et de la collecte de métriques.

Mots-clés :.NETPerformanceProfilingBackendOptimization
Y

Yva Hajatiana

Lead développeur .NET à Paris et architecte logiciel, 10 ans d'expérience sur des SI critiques bancaires (.NET, Angular, systèmes distribués, IAM, DevOps).