Journal Comparatif d'outils d'analyse mémoire

36
21
avr.
2022

Sommaire

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 ?

  • # Pourquoi ?

    Posté par  (Mastodon) . Évalué à 4.

    Pourquoi ASan avec GCC et MSan avec Clang ? Les deux outils (ASan et MSan) ne visent pas du tout les mêmes types d'erreurs (et il me semble même qu'on peut les combiner). Il aurait fallu voir les deux outils avec les deux compilateurs (même si je pense que ça ne change pas grand chose, mais c'est mieux de le vérifier).

    • [^] # Re: Pourquoi ?

      Posté par  (site web personnel) . Évalué à 2.

      J'ai le souvenir inverse: que msan et asan ne sont pas compatibles, alors que asan est compatible avec ubsan—et pour le coup, aucune raison de ne pas les combiner: on peut plus sereinement faire tourner les tests en -O2 -g.

    • [^] # Re: Pourquoi ?

      Posté par  (site web personnel) . Évalué à 3.

      Pour la machine dans le nuage j'ai gardé le GCC de base car c'était notre second compilateur de référence. Comme c'est un vieil OS avec de vieux outils je n'avais pas de quoi y tester MSan avec Clang. Pour ma machine locale j'ai gardé GCC pour ASan afin de pouvoir comparer les résultats d'une simple mise à jour de compilateur.

      Dans l'absolu j'imagine que le compilateur ne joue pas tellement sur les résultats d'ASan puisque c'est une lib développée indépendamment.

      • [^] # Re: Pourquoi ?

        Posté par  (site web personnel) . Évalué à 1.

        Si tu regardes les sorties d'asan dans ma réponse en dessous, tu verras que cela ne pointe pas au même endroit entre l'asan de clang et celui du système/gcc.

        J'ai d'ailleurs une nette préférence à avoir l'asan de clang++ que je me compile régulièrement. Il est ainsi un chouilla plus à jour, mais aussi il se perd moins souvent pour retrouver ce qui est fait à telle ou telle ligne.

  • # Résultats différents pour asan

    Posté par  (site web personnel) . Évalué à 2. Dernière modification le 21 avril 2022 à 11:21.

    Etant assez surpris des résultats de asan pour certains tests comme alloc-missing-delete, je l'ai relancé sur mon ubuntu 18 avec g++7.5 et un clang++ 13 (compilé main), avec $CXXFLAGS='-fsanitize=address -g -fno-omit-frame-pointer'. Les deux voient la fuite de mémoire

    # avec g++
    =================================================================
    ==301==ERROR: LeakSanitizer: detected memory leaks
    
    Direct leak of 4 byte(s) in 1 object(s) allocated from:
        #0 0x7f54ad2ff448 in operator new(unsigned long) (/usr/lib/x86_64-linux-gnu/libasan.so.4+0xe0448)
        #1 0x5576a8634852 in main /home/moi/dev/tests/c++/alloc-missing-delete.cpp:3
        #2 0x7f54ace4fc86 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21c86)
    
    SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s).
    
    # avec clang++
    =================================================================
    ==1267==ERROR: LeakSanitizer: detected memory leaks
    
    Direct leak of 4 byte(s) in 1 object(s) allocated from:
        #0 0x4c8c5d in operator new(unsigned long) /home/moi/local/clang/src/llvm/compiler-rt/lib/asan/asan_new_delete.cpp:99:3
        #1 0x4cb57f in main /home/moi/dev/tests/c++/alloc-missing-delete.cpp:3:18
        #2 0x7f5ff312cc86 in __libc_start_main /build/glibc-uZu3wS/glibc-2.27/csu/../csu/libc-start.c:310
    
    SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s).
    

    Au passage, clang-tidy voit la fuite aussi:

    2 warnings generated.
    /home/moi/dev/tests/c++/alloc-missing-delete.cpp:3:10: warning: Value stored to 'value' during its initialization is never read [clang-analyzer-deadcode.DeadStores]
        int* value = new int(argc);
             ^
    /home/moi/dev/tests/c++/alloc-missing-delete.cpp:3:10: note: Value stored to 'value' during its initialization is never read
    /home/moi/dev/tests/c++/alloc-missing-delete.cpp:7:5: warning: Potential leak of memory pointed to by 'value' [clang-analyzer-cplusplus.NewDeleteLeaks]
        return 0;
        ^
    /home/moi/dev/tests/c++/alloc-missing-delete.cpp:3:18: note: Memory is allocated
        int* value = new int(argc);
                     ^
    /home/moi/dev/tests/c++/alloc-missing-delete.cpp:7:5: note: Potential leak of memory pointed to by 'value'
        return 0;
        ^
    
    • [^] # Re: Résultats différents pour asan

      Posté par  (site web personnel) . Évalué à 3.

      Zut. Je n'ai pas réussi à éditer ma réponse.

      Je n'ai rien dit. Je n'avais pas fais attention qu'il y avait un second tableau pour la configuration avec un environnement plus récent.

      Ceci dit, je me suis déjà compilé des versions récentes de clang++ pour CentOS. C'est fort pratique pour avoir des outils tels que les sanitizers, de meilleurs warnings, clang-tidy, un serveur LSP, etc même quand on est restreints à du C++ 98.

      • [^] # Re: Résultats différents pour asan

        Posté par  (site web personnel) . Évalué à 2.

        Ceci dit, je me suis déjà compilé des versions récentes de clang++ pour CentOS. C'est fort pratique pour avoir des outils tels que les sanitizers, de meilleurs warnings, clang-tidy, un serveur LSP, etc même quand on est restreints à du C++ 98.

        Si tu as des conseils pour avoir un Clang 13 ou ultérieur sur CentOS 7.9 ça m'intéresse. De souvenir je n'avais pas pu le compiler car le compilateur installé, GCC 4.8 ne supportait pas une version suffisamment récente de C++.

        • [^] # Re: Résultats différents pour asan

          Posté par  (site web personnel) . Évalué à 3.

          Tu peux t'installer les dev-tools (?) de red-hat pour avoir un gcc plus récent pour commencer. Cela permettra ensuite de compiler clang.

          J'ai cette "vieille" procédure que je mets à jour tous les 4 matins car il y a régulièrement des petits changements: https://github.com/LucHermitte/install-clang
          Ma dernière utilisation en date, c'était pour du ubuntu 18, clang 13.0.1, et il y a un petit hic qui fait que tout n'est pas automatisé. J'avais du reprendre la main et relancer en cours de route :(

          NB: j'ai aussi fait le choix que clang soit compilé par lui-même avec dépendance à libc++ dans la dernière itération. Et que la lib standard par défaut soit libc++ (et non stdlibc++ du système). C'est parfois un peu casse-pieds dans un monde linux sur des gros projets avec plein de dépendances dans tous les sens et des procédures de compilation très hétérogènes.

          PS: Après je me suis installé lmod (que j'ai découvert après un passage dans le monde du HPC) pour switcher rapidement entre diverses versions de compilateurs.

  • # Test manquant ?

    Posté par  (site web personnel) . Évalué à 7.

    J'ai l'impression qu'il manque un test qui est pourtant important pour distinguer les approches/outils :

    #include <stdio.h>
    
    int main(int argc, char** argv)
    {
        int values[4] = {1, 2, 3, 4};
        int other_values[4] = {10, 20, 30, 40};
        printf("%d\n", values[4]);
        return 0;
    }

    L'exécution du code compilé avec GCC va déclencher un accès à other_values[0] qui est "légitime" au sens où c'est une valeur qui existe, qui est proprement initialisée. Valgrind n'y voit que du feu (et ne pourrait pas trop faire autrement), mais address sanitizer ajoute le check de débordement de tableau qui va bien à la compil et trouve l'erreur à l'exécution.

  • # intéressant

    Posté par  . Évalué à 4.

    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}}.

    ça veut dire qu'il fonctionne sur un binaire dont on a pas les sources? Genre un vieux truc qui pète de temps en temps, je le monitor avec Inspector, je lui envoie du data random et je regarde quand est-ce qu'il crash? Ca peut être pas mal pour trouver un corner case un peu cheulou?

    Ou alors il faut malgré tout recompiler le binaire? (pour un fuzzing un peu dumb ça pourrait être bien aussi, là j'ai ASAN qui est bien mais je testerai bien d'autres tools :) )

    • [^] # Re: intéressant

      Posté par  (site web personnel) . Évalué à 4.

      ça veut dire qu'il fonctionne sur un binaire dont on a pas les sources?

      Tout à fait, Valgrind et Inspector peuvent trouver des erreurs sans des binaires sans avoir les sources. Par contre s'il n'y a pas les symboles de debug ils vont avoir du mal à dire de quelle ligne ça vient. De tête il me semble qu'ils affichent l'adresse de la fonction.

      • [^] # Re: intéressant

        Posté par  (site web personnel) . Évalué à 5.

        trouver des erreurs sans des binaires sans avoir les sources.

        Ce qui est assez fort en vrai ! ;-)

        Mais plus sérieusement, c'est une force de ces outils : on peut les utiliser dans rien changer au build (genre en tant qu'enseignant, juste ajouter valgrind devant la ligne de commande d'exécution sans avoir à subir les atrocités que l'étudiant a écrit dans son Makefile…).

        • [^] # Re: intéressant

          Posté par  (site web personnel, Mastodon) . Évalué à 2.

          trouver des erreurs sans des binaires sans avoir les sources.

          Ce qui est assez fort en vrai ! ;-)

          Mais plus sérieusement, c'est une force de ces outils : on peut les utiliser dans rien changer au build (genre en tant qu'enseignant, juste ajouter valgrind devant la ligne de commande d'exécution sans avoir à subir les atrocités que l'étudiant a écrit dans son Makefile…).

          La compensation est assez forte aussi ;-)

          “It is seldom that liberty of any kind is lost all at once.” ― David Hume

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.