Ce post a été publié sur LinuxFr.org. Vous pouvez le lire là bas, ainsi que les commentaires associés
Cher journal,
Il n’y a pas si longtemps, j’ai dû faire un comparatif d’outils d’analyse mémoire dans nos programmes, pour le boulot. Tu connais sûrement ce genre d’outils, tels que Valgrind ou Address Sanitizer, sous le nom de memory sanitizers. Ces deux là sont assez connus mais il en existe d’autres tels que Dr. Memory (que je ne connaissais pas) ou encore Intel Inspector (que je ne connaissais qu’à peine).
D’une manière générale ces outils fonctionnent en gardant une carte des zones mémoires allouées (pile ou tas) et de leur état (initialisée ou pas). Chaque accès mémoire est alors instrumenté pour vérifier que cela correspond à une zone mémoire allouée et si les valeurs utilisées ont bien été initialisées. Pour cela il y a deux approches, soit l’instrumentation est injectée à la compilation, c’est la solution Address Sanitizer, soit elle est faite a posteriori, c’est la solution Valgrind ou Inspector.
Au boulot nous utilisions l’outil d’Intel, en version 2016. Le process est simple, il suffit de prendre le binaire de notre programme, le lancer avec Inspector et les bons paramètres, puis de regarder les erreurs qu’il remonte. Évidemment il ne remonte rien puisque nous développons parfaitement {{À prouver}}. En tout cas cela se met très bien dans une CI.
2016 c’est un peu vieux, alors un jour, comme ça, voyant un 2018 traîner dans un coin, j’ai décidé de faire une petite mise à jour. Et paf ! Les temps d’analyse ont explosé. Il faut savoir que ce genre d’outil est déjà lent par défaut, mais là ça en était devenu insupportable. Six heures d’attente pour avoir les résultats pour une PR, c’est trop.
C’est alors que j’ai décidé de regarder ce qu’il se faisait ailleurs.
Protocole de tests
Pour tester ces outils j’ai rassemblé une trentaine de programmes contenant des erreurs intentionnelles, par exemple celui-ci qui lit au-delà de la fin d’un tableau global :
#include <cstdio>
int values[5] = {1, 2, 3, 4, 5};
// Lancez ce programme avec quatre argument pour déclencher une
// lecture hors bornes.
int main(int argc, char** argv)
{
printf("%d\n", values[argc]);
return 0;
}
Le jeu de test contient :
- 11 problèmes d’allocation (fuites, non-correspondance des appels à new et delete, …) ;
- 7 lectures hors bornes dans des tableaux ;
- 7 écritures hors bornes ;
- 4 utilisations de variables non initialisées ;
- 1 utilisation d’une variable sur la pile après être sorti de la fonction ;
- et 1 cas légitime d’une copie de mémoire non initialisée (qui ne doit donc pas être détecté comme une erreur).
Ensuite je compile le tout sans optimisations, pour éviter que le compilateur ne vire tout le code parce qu’il a compris que ça n’avait aucun sens, et avec les options qui vont bien pour les outils intrusifs.
Et enfin je lance le test avec l’outil qui va bien. Les outils sont :
- Address Sanitizer, en utilisant GCC ;
- Memory Sanitizer, en utilisant Clang ;
- Valgrind ;
- Intel Inspector.
Pour Inspector je teste les versions 2016 à 2020 même si c’est un peu vieux, parce qu’avec la hausse des temps que nous avons eu avec 2018 je préfère ratisser large, quitte à prendre une version plus ancienne si elle est plus rapide pour le même service rendu.
Et pour finir tout cela est lancé sur deux environnements. Une machine est dans les nuages, avec CentOS 7.8, GCC 4.8 (modern C++ for the win!), Inspector 2016-2020, Valgrind 3.15, un Intel Xeon à 2.30GHz et 16 cœurs. L’autre machine est sur mon bureau, avec Ubuntu 21.04, GCC 10.3, Clang 12, Inspector 2020, Valgrind 3.17, tournant dans Hyper V avec 100% des CPUs dédiés à la VM, et un i7-8665U à 1.90GHz avec 8 cœurs.
Les résultats
Pour les résultats je te la fait courte mais tu peux trouver le détail par type de test dans le dépôt public, ainsi que tout le code et tout ce qu’il faut pour reproduire les tests.
L’outil le moins efficace sur ce jeu de tests est Memory Sanitizer, qui ne détecte que deux erreurs, qui sont aussi détectée par les autres outils.
Address Sanitizer est très bon, il ralentit peu le programme testé, trouve de nombreuses erreurs, y compris des erreurs non détectées par les autres outils.
Valgrind et Inspector 2020 sont aussi bons l’un que l’autre. Ils sont très lents, du même ordre de grandeur, mais trouvent aussi des erreurs non détectées par Address Sanitizer. Les deux détectent les mêmes erreurs.
Inspector 2020 est la seule version valable de cet outil. 2016 et 2017 ont été incapables de lancer les tests ; 2018 et 2019 trouvent une erreur sur le test où il n’y en a pas.
Sur des cas d’utilisation concrets j’ai mesuré des ralentissements d’un facteur 3 à 13 avec Address Sanitizer et 163 à 565 avec Inspector ou Valgrind. Avec ces deux derniers on paye beaucoup le fait que l’exécution devient mono-thread (notre programme est fortement multi-thread), et j’ai aussi observé que l’augmentation des allocations de mémoire impactaient grandement les temps d’exécution, pour le pire, de même pour l’augmentation de la sollicitation de mutexes.
Certaines erreurs ne sont jamais détectées, notamment des accès hors bornes sur des tableaux dans des structures. D’après le fonctionnement des outils il semble peu probable qu’ils puissent les détecter un jour. Il faudrait insérer du padding entre les champs à la compilation, ce qui ne m’a pas l’air trivial. D’un autre côté l’erreur est remontée via un avertissement par GCC 10 et suivantes.
Le mot de la fin
Au final nous avons migré vers Valgrind, principalement parce que nous avions un historique de faux positifs avec Inspector qui lui a donné une mauvaise image. Je t’invite quand même à jeter un coup d’œil à ce dernier ; ce n’est pas libre mais c’est au moins gratuit.
Je te remets le lien vers les tests, c’est sur notre GitHub.
Ceci étant fait, je me penche maintenant sur les problèmes de threading. As-tu déjà essayé Thread Sanitizer, Helgrind ou DRD (de Valgrind), ou l’équivalent d’Inspector ?