Graphes d’appels

Motivation

Parfois, il arrive qu’on observe une activité système dont on ne sait pas vraiment d’où elle sort. Dans ce cas, il est utile de savoir quelle partie du programme appelant a causé cette activité système.

Par exemple, sur le résumé des appels système de la commande dd étudiée précédemment…

Sommaire perf trace de l’exécution de dd

…on pourrait être surpris de voir 17 occurences de l’appel système fstat(), qui sert à obtenir des informations sur un fichier. Après tout, l’outil dd n’a qu’un fichier en entrée et un fichier en sortie, donc on voit mal pourquoi il va se poser des questions sur au moins 15 autres fichiers.

Connaître les paramètres et le résultat de l’appel système n’aide pas trop ici :

srun --pty \
    perf trace -e 'fstat' \
    dd if=/dev/urandom of=/hdd/${USER}/randomness.bin bs=42 count=123 oflag=sync

Analyse perf trace des appels fstat de dd

En revanche, connaître par quel chemin dans le programme (pile d’appels) on en est arrivé à effectuer cet appel système aide beaucoup plus…

srun --pty \
    perf trace -e 'fstat' --call-graph=dwarf \
    dd if=/dev/urandom of=/hdd/${USER}/randomness.bin bs=42 count=123 oflag=sync
Sortie de perf trace

…et c’est cela que perf appelle le graphe d’appels (call graph) : un graphe qui, pour une fonction donnée, indique par quelles autres fonctions elle est appelée et quelles autres fonctions elle appelle, dans un contexte donné (ici les appels système fstat). On parle de graphe d’appel et pas d’arbre d’appel parce qu’une fonction peut s’appeler elle-même récursivement.

Avec cette information supplémentaire, nous apprenons ici que les deux premiers appels à fstat ont été effectués pendant le chargement du programme et de ses dépendances par le dynamic loader et que les 15 appels suivants ont été fait dans le cadre de la configuration de la locale de la glibc, c’est à dire le support d’un affichage dans plusieurs langues (ce qui implique d’afficher les nombres décimaux de format différent par exemple).

Mais revenons un instant sur l’option --call-graph=dwarf que nous avons utilisée pour obtenir cette information. Comme vous pouvez vous en douter à sa syntaxe, perf supporte plusieurs techniques différentes pour reconstruire le graphe d’appels, chacune avec ses avantages et inconvénients. Et le sujet est assez complexe et important pour mériter une parenthèse théorique.

Techniques de reconstruction

Frame pointers (--call-graph=fp)

Les frame pointers étaient historiquement utilisés par la plupart des outils de déboguage, profilage et analyse dynamique. L’idée générale de cette approche, c’est que dans une convention d’appel de fonction typique, à chaque fois qu’un programme appelle une fonction, il pousse sur la pile des informations permettant de remonter à la fonction appelante, puis à l’appelant de la fonction appelante, et ainsi de suite. Et à chaque instant, un registre CPU (ebp/rbp sous x86) permet aux outils d’analyse de remonter cette liste chaînée.

Le problème, c’est qu’avec le temps, les compilateurs ont trouvé des techniques pour gérer la pile d’appels sans utiliser un registre CPU pour ça. Et comme les registres CPU sont une ressource très précieuse, ils ont commencé à se servir du registre ebp pour toutes sortes d’autres choses. Cette approche de reconstruction de pile d’appels ne fonctionne donc plus sur les programmes compilés avec les versions actuelles de GCC et clang, du moins avec leurs réglages par défaut. Pour pouvoir l’utiliser, il faut recompiler toute la portion de sa pile logicielle qu’on cherche à analyser avec l’option -fno-omit-frame-pointer, ce qui n’est généralement pas acceptable.

Last Branch Record (--call-graph=lbr)

Une deuxième technique consiste à utiliser le Last Branch Record (LBR) des processeurs Intel. Il s’agit d’une fonctionnalité d’analyse de performances du CPU qui mémorise les derniers sauts effectués par le programme. Depuis la génération Haswell (2013), on peut la configurer pour suivre chaque appel aux instructions CALL et RET, qui sont typiquement utilisées par les compilateur pour implémenter les appels de fonction. Le CPU maintiendra en fonction une pile d’appel dans une petite banque de registres, que perf lira de temps en temps.

Le problème, c’est que cette méthode ne fonctionne…

  • Que sur certains matériels (CPUs Intel de génération >= Haswell).
  • Que jusqu’à une certaine longueur de pile d’appels (32 sur srv-calcul-ambulant, 16 pour les générations avant Skylake), au-delà de laquelle la pile d’appel mesurée sera tronquée. Cette limite suffit pour de petits programmes, mais est insuffisante pour analyser un programme qui utilise beaucoup la récursion comme l’interpréteur CPython.
  • Que sur des programmes qui ont une configuration d’appel de fonction “classique” (pas de catch d’exceptions C++, pas de coroutines, et si le compilateur utilise des implémentations alternatives d’appel de fonction comme l’inlining ou la récursion terminale, le LBR ne verra pas ces appels et donc perf non plus…).

De plus, pour des raisons mystérieuses, il n’est actuellement (Linux 6.0) pas possible d’utiliser cette méthode quand on surveille des tracepoints, comme le fait perf trace. Elle n’est compatible qu’avec les événements PMU (Performance Monitoring Unit du CPU). Une discussion sur la mailing list de perf suggère que c’est une limitation au niveau du noyau Linux qui pourrait être levée par une version future de perf et du noyau.

Informations de déboguage (--call-graph=dwarf)

En raison des inconvénients des approches précédentes, on doit souvent avoir recours à une troisième technique, où on enregistre les derniers kilo-octets de la pile du programme, puis les analyse en différé avec des informations de déboguage. C’est la même technique qu’utilisent les débogueurs pour afficher des piles d’appel.

Le nom “dwarf” de cette méthode est une référence au format DWARF utilisé pour stocker les informations de déboguage dans les binaires ELF utilisés par Linux.

Même si c’est souvent la seule disponible, cette méthode n’est pas non plus sans inconvénients :

  • Elle suppose que les derniers kilo-octets de la pile du programme suffisent à reconstruire la pile d’appels. Ce n’est pas vrai si le programme alloue des grosses variables sur la pile ou a des piles d’appel profondes. On peut augmenter la taille de la région de pile copiée, par exemple à 30 Ko avec la syntaxe --call-graph=dwarf,30000, mais perf refusera au-delà de 64 Ko.
  • Copier des kilo-octets de pile à chaque fois qu’un événement survient a un coût en bande passante mémoire qui n’est pas négligeable, et analyser cette information à posteriori consomme une grande quantité de temps CPU. Dans le contexte de perf trace, on devra donc se restreindre à étudier des événements peu fréquents avec cette méthode.
  • Pour effectuer l’analyse finale, il faut disposer des informations de déboguage de tous les programmes et bibliothèques utilisés pendant le job à analyser (option -g de GCC et clang pour les programmes qu’on compile soi-même, paquets -debuginfo sous openSUSE/Fedora/RHEL et -dbg sous Debian/Ubuntu).
  • Et même quand l’ensemble de ces conditions sont remplies, la reconstruction de piles d’appels ne va pas toujours jusqu’au bout, pour des raisons variées et difficile à élucider (bogues de compilateur, de binutils, de perf…).

Pour résumer, quand on veut des graphes d’appels avec perf, on est généralement avisé de commencer par l’option --call-graph=dwarf, aggrémentée d’une taille de copie plus grosse que la valeur par défaut si nécessaire pour de bons résultats, et de prendre l’habitude d’installer les symboles de déboguage pour les logiciels/bibliothèques dont on peut être amené à analyser la performance sur ses machines de développement, ainsi que leurs dépendances.

A l’avenir, cette tâche rébarbative devrait être simplifiée par la maturation de l’écosystème debuginfod, qui vise à permettre une récupération automatique des symboles de déboguage par perf (entre autres) pour les paquets de toutes les distributions Linux courantes.