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…
…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
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 doncperf
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
, maisperf
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
, deperf
…).
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.