Durée de lecture : environ 6 minutes

Dans le précédent article, nous avons vu comment paralléliser des appels séquentiels. Aujourd’hui, je souhaite adresser un sujet similiaire du point de vue algorithmique : trouver et optimiser les problèmes de performances en C#. Et en particulier les allocations de chaînes de caractères et les ordres de complexité temporelles algorithmiques (O(n), O(n²), O(log n), etc…).

Un peu d’histoire

Comme dans l’article précédent, j’investiguais des sujets de performances vers mai 2023. Après avoir corrigé quelques problèmes liés à nos dépendances, je voulais regarder comment se comportaient nos APIs en production. Je pouvais évidemment compter sur la vue Performances dans Application Insights pour me donner un aperçu par route et même me permettre de voir ce que donnait chaque dépendance. Mais il me manquait une vue en interne me disant quelles parties du code sollicitaient un peu trop le CPU ou la mémoire.

Le profiler d’Application Insights

Après une discussion rapide avec notre expert Azure, j’ai vu que nous pouvions activer un profiler directement dans Azure. Ce profiler a un coût non négligeable, donc l’activer aurait des répercussions sur les performances de nos sites. Donc nous nous sommes mis d’accord pour l’activer sur une courte période, voir comment tout se comportait et décider ensuite si on réitèrerait l’approche.
Nous avons fini par l’activer pendant quelques heures par jour pendant quelques jours. Cela m’a donné accès à la page des optimisations de code qui liste des problèmes potentiels observés par Azure pendant que le profiler était activé.

Exemple de la page "Optimisations de code" dans Azure. Une courte introduction figure au-dessus d'un grand tableau. Ce tableau comporte deux sections : une section "Mémoire" qui répertorie quatre problèmes potentiels classés par utilisation maximale (par ordre décroissant) et une section "CPU" qui répertorie cinq problèmes potentiels classés selon le même ordre.

Le tableau peut être filtré en fonction de la période, du rôle, de l'abonnement, des ressources ou du type d'informations.

Chaque problème est accompagné d'un bref résumé, tel que "Allocations excessives dues à String.Ctor()".

Lorsqu'on sélectionne une ligne dans le tableau, les détails s'affichent à droite de la page, indiquant la nature du problème et une recommandation pour le résoudre.

Premières découvertes

En haut de la liste figuraient des choses sur lesquelles je n’avais aucun contrôle : désérialisation d’appels à la base de données, task scheduler de GraphQL, Regex (faites attention à bien suivre les bonnes pratiques lorsque vous les utilisez), etc… Toutefois, j’ai fini par trouver quelque chose de bizarre qui allouait pas mal d’objets et prenait aussi du temps CPU 🤔 J’ai regardé le code et…

public static string? GetTranslation(IEnumerable<TranslationCacheDto> translations, string itemName)
{
    return translations.FirstOrDefault(t => $"cache.key.{itemName?.Replace("/\\s/g", "").ToLower()}".Equals(t.CacheKey, StringComparison.InvariantCultureIgnoreCase))?.TranslatedText;
}

Le moins qu’on puisse dire c’est que c’est le bazar et à l’origine c’était sur une seule ligne. Comme je l’ai appris avec le temps, c’est généralement une bonne idée de découper les « one-liners » pour mieux les comprendre. Dans notre cas, le gros de la logique est située dans le prédicat du FirstOrDefault(). Voici grosso modo ce qu’il fait :

  1. Remplace quelque chose dans la variable itemName donnée en paramètre ;
  2. Puis la convertit en minuscule ;
  3. Puis l’ajoute par interpolation à la fin de la chaîne « cache.key. » ;
  4. Puis compare cette valeur avec la propriété CacheKey de chaque traduction en ignorant la casse et en utilisant la culture invariante ;
  5. Enfin, renvoie la propriété TranslatedText de la première traduction correspondante ou null.

Premières pistes

Plusieurs choses me viennent à l’esprit :

  1. Utiliser ToLower() et comparer avec InvariantCultureIgnoreCase donnent le même résultat. Donc on peut déjà supprimer cet appel à ToLower() et garder le comparateur. Cela nous débarrasse d’une allocation de chaîne inutile effectuée à chaque itération du FirstOrDefault().
  2. De la même manière, la concaténation de chaîne via interpolation est effectuée à chaque itération elle aussi. On peut faire cette interpolation en-dehors du FirstOrDefault() pour un gain non négligeable en allocation mémoire.
  3. Cet appel à String.Replace() a l’air chelou. On dirait que le développeur d’origine a voulu passer une Regex pour supprimer les espaces… sauf que String.Replace() ne sait pas gérer les expressions régulières. Pour ce que ça vaut, j’ai vérifié en base de données et aucun item ne contient la chaîne « /\s/g ».

On dirait qu’on peut améliorer les choses facilement ici ! J’ai ajouté quelques tests pour être sûr que je ne cassais rien et j’ai fait mes modifications. Mais comment être sûr que je réparais vraiment quelque chose ?

Code d’exemple !

Implémentons le code original et le code « corrigé »

La première analyse a l’air prometteuse sur le papier mais testons-la. Pour ce faire, j’ai créé un repository dédié ; n’hésitez pas à y jeter un oeil. La classe BadImplem est celle qui contient l’algorithme original. La classe ImprovedImplem contient les améliorations listées plus haut, ce qui nous donne le code suivant :

public static string? GetTranslation(IEnumerable<TranslationCacheDto> translations, string itemName)
{
    var key = $"cache.key.{itemName}";
    return translations
        .FirstOrDefault(translation => key.Equals(translation.CacheKey, StringComparison.InvariantCultureIgnoreCase))
        ?.TranslatedText;
}

Est-il possible de l’améliorer encore ? Le but de cet algorithme semble être de chercher une valeur spécifique puis de la renvoyer. En itérant sur une liste et en comparant chaque élément, la complexité temporelle de l’algorithme est en O(n). En pratique, itérer de temps en temps sur 10 éléments d’une liste n’aura pas de grande incidence d’un point de vue des performances. Mais en regardant les vrais chiffres sur notre environnement de production, il est apparu que la liste contenait plus de 50 000 éléments et que l’algorithme était utilisé plusieurs fois par seconde. Ce n’est pas bon, ces améliorations ne seront peut-être pas suffisantes.

Un graphique représentant différentes courbes, expliquant la relation entre le nombre d'opérations et le nombre d'éléments.

O(n!) est le pire cas, car le nombre d'opérations augmente extrêmement rapidement lorsque le nombre d'éléments augmente.

Le meilleur cas est O(1) (temps constant), suivi de O(log n), puis O(n), O(n log n), O(n²), O(2^n) et enfin O(n!).

Penser en O(1)

On est sur un cas où je vais penser immédiatement à utiliser un Dictionary. Retrouver un élément dans un Dictionary a une complexité temporelle en O(1), ce qui est la façon la plus rapide. Il y aura en revanche une contrepartie lors de la création du Dictionary donc il faudra la mesurer aussi. Quoi qu’il en soit, j’ai ajouté le code suivant pour construire le Dictionary dans Program.cs :

void InitializeDictionary(Dictionary<string, string> dictionary, TranslationCacheDto[] translationDtos, string[] itemNames)
{
    for(var i = 0; i < 50000; i++)
    {
        dictionary.Add(itemNames[i], translationDtos[i].TranslatedText);
    }
}

Et le code trivial suivant pour lire depuis le Dictionary :

public static string GetTranslation(IDictionary<string, string> translationsByItemName, string itemName)
{
    return translationsByItemName[itemName];
}

Mesures

OK, nous allons maintenant voir comment comparer ces 3 approches entre elles. Regardez le contenu de Program.cs. En gros, une fois que les données et le dictionnaire sont en place, nous essaierons de trouver une valeur aléatoire 1000 fois d’affilée. Puis nous regarderons combien de CPU aura été consommé et combien d’allocations mémoire auront été effectuées pendants ces opéarions.
On compile en mode Release, on appuie sur Alt+F2 dans Visual Studio, on choisit le profiler « CPU Usage » eeeeet…

Utilisation CPU

Arborescence des appels enregistrés par Visual Studio. Les appels sont classés par pourcentage d'utilisation du CPU par ordre décroissant.

BadImplem est le plus gros consommateur de ressources CPU, avec 59,89 %.

ImprovedImplem utilise ensuite 32,91 % du CPU.

Enfin, la création du Dictionary dans InitializeDictionary utilise 0,5 % du CPU et la recherche des valeurs 0,02 %.

Voilà. On peut maintenant dire avec une certaine confiance que la première implémentation corrigée divise quasiment par 2 l’utilisation CPU.

Quoi d’autre ? Évidemment l’implémentation en O(1) bat tout le monde les mains dans les poches. Même construire le Dictionary ne représente qu’une fraction du coût des 2 autres approches. Construire le Dictionary ET y chercher des valeurs aléatoires prend 1% du coût de la version originale ! C’est donc la meilleure méthode ? Pas si vie, regardons d’abord les allocations mémoire. (Note : afin de réduire la durée d’analyse, j’ai baissé le nombre d’éléments de 1000 à 200).

Allocations mémoire

Arborescence des appels enregistrée par Visual Studio.

Le pire cas est une fois de plus la version BadImplem avec 10 millions d'allocations et une empreinte mémoire (cumulée) d'environ 310 Mo.

Vient ensuite InitializeDictionary, qui n'alloue que 32 objets et dont l'empreinte mémoire est d'environ 3,9 Mo.

Enfin, nous avons ImprovedImplem, qui effectue 600 allocations tout en ne prenant que 17 Ko.

En effet, l’initialisation du Dictionary alloue très peu d’objets (bien sûr, les TranslationDtos étaient déjà créés par InitializeData()). Mais il consomme une quantité non négligeable de mémoire avec près de 4 Mo. En revanche, ImprovedImplem a très peu d’allocations ET ne consomme quasiment pas de mémoire.

Conclusion

Quelle conclusion peut-on donc tirer de tout cela ? Comme beaucoup de réponses dans le développement logiciel, « ça dépend« . Bien sûr, la première implémentation n’était pas optimale donc il est probablement préférable de choisir l’une des deux autres. Mais laquelle ? Chaque solution a ses avantages et inconvénients et je n’ai proposé ici que 2 solutions.

Si vous vous retrouvez dans un contexte où vous devez reconstruire le Dictionary de 0 à chaque requête et que la mémoire que vous pouvez utiliser est limitée, alors ImprovedImplem en O(n) mérite d’être prise en considération. Mais si les ressources CPU sont un sujet et qu’un usage temporaire de 4 Mo de mémoire n’est pas un souci, alors j’utiliserais probablement l’implémentation à base de Dictionary.

Une autre leçon importante à tirer ici est : faites très attention à chaque fois que vous manipulez des chaînes de caractères et que vous les comparez ! Les chaînes de caractères sont immutables, donc chaque modification créera de nouvelles instances, ce qui requiert beaucoup d’allocations mémoire.

J’ai à peine effleuré la surface de ce qui peut être accompli avec des profilers. L’idée était de vous donner un aperçu de code basique ainsi qu’une introduction rapide aux outils disponibles. J’espère que cela vous aura été utile ! Pour aller plus loin, on s’intéressera à la librairie BenchmarkDotNet qui permet de faire tourner des benchmarks et des mesures poussées simplement.

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things.
I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs.
This is why I favor a test-first approach to focus on the problem space and ask questions to business experts before even coding the feature.

Catégories : Dev

Guillaume Téchené

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things. I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs. This is why I favor a test-first approach to focus on the problem space and ask questions to business experts before even coding the feature.