Introduction

perf (également appelé perf_events) est un ensemble d’outils pour analyser la performance des systèmes Linux. Ces outils s’appuient sur une infrastructure présente dans le noyau Linux depuis sa version 2.6.31 (2009), avec laquelle ils entretiennent une relation très étroite, les outils perf étant maintenus au sein du code source du noyau Linux. Une analyse à grain fin de l’activité CPU est également possible via des compteurs de performance (Performance Monitoring Unit ou PMU), définis par chaque microarchitecture CPU.

En seulement une dizaine d’années d’existence, perf est devenu une référence dans plusieurs domaines, incluant notamment l’étude à grain fin de l’activité CPU et l’instrumentation dynamique de code noyau et applicatif. Dans ce TP, nous allons explorer les différentes possibilités offertes par perf aujourd’hui (2022), en l’utilisant pour analyser l’exécution de programmes simples mais néanmoins représentatifs des différentes formes d’activité logicielle qu’il sait décortiquer.

Le TP est pensé pour être suivi dans l’ordre, chaque section pouvant ainsi se référer aux informations présentées dans la section précédente. Il se décompose en trois grandes parties :

  • Dans une première partie, nous étudierons les fonctionnalités élémentaires que tout utilisateur de perf devrait connaître, du simple comptage d’événements (perf stat) au profilage de code (perf report) en passant par le suivi d’activité noyau (perf trace).
  • Dans une seconde partie, nous aborderons des fonctionnalités plus avancées, allant du profilage en temps réel du système entier (perf top) à l’analyse détaillée des accès mémoire (perf mem, perf c2c) en passant par l’instrumentation de code arbitraire (perf probe).
  • Enfin, des annexes abordent plusieurs thématiques qui ne sont pas couvertes en TP pour diverses raisons (manque de temps, intérêt plus faible, nécessitent des privilèges trop importants…), mais sont utiles à connaître dans la pratique quotidienne de l’outil.

Le TP est pensé pour être suivi sur srv-calcul-ambulant, nous supposerons donc que vous avez déjà un compte sur cette plate-forme et savez vous en servir. En cas de besoin, une copie de la documentation du serveur est disponible en local à l’URL http://srv-calcul-ambulant/ 🇬🇧, et cette documentation est également publiée sur Internet 🇬🇧.

Si vous souhaitez adapter le TP à une autre plate-forme, son code source est disponible sur https://gitlab.in2p3.fr/grasland/tp-perf sous licence CC-BY-SA 4.0. Le rendu Markdown est assuré par mdBook et les petits programmes utilisés pour les TPs sont stockés dans le répertoire code/.

perf stat

perf est construit autour de la notion de gestion d’événements système.

Un événement au sens de perf peut être toutes sortes de choses, allant du passage d’un cycle processeur à un appel système en passant par des défauts de cache CPU et des écritures disque. L’idée centrale de perf est d’abstraire autant que possible la différence entre ces diverses sortes d’événements, pour pouvoir effectuer de nombreuses activités d’analyse de performances avec un jeu d’outils d’analyse relativement petit.

La chose la plus simple que perf puisse faire avec des événements, c’est de les compter via la commande perf stat, et c’est donc par cette activité que nous allons démarrer ce TP.

Premier contact

Au cours de ce TP, vous serez amenés à compiler et exécuter des codes d’exemple. Commençons par télécharger et extraire ceux-ci dans votre répertoire personnel :

cd ~
curl -L http://srv-calcul-ambulant/docs/tp-perf/tp-perf.tar.gz | tar -xz
cd tp-perf

N’oubliez pas de compiler avec srun pour ne pas surcharger les coeurs CPU interactifs :

srun make -j$(nproc)

Pour introduire perf stat, nous allons commencer par un code d’exemple extrêmement simple inspiré du benchmark STREAM, appelé scale. Ce code part d’un tableau de nombres flottants, en multiplie chaque élément par une constante, et stocke le résultat dans un autre tableau. Il prend deux paramètres, le premier est la taille des tableaux, et le second est le nombre de fois que le calcul doit être effectué.

Par exemple, pour multiplier deux mille flottants dix millions de fois, nous ferions…

srun --pty ./scale.bin 2048 10000000

Partant de là, pour instrumenter l’exécution de cet exemple avec perf stat, il suffit d’injecter une commande perf stat avant le nom de l’exécutable, comme ceci :

srun --pty perf stat ./scale.bin 2048 10000000

Et nous obtenons ce genre de résultats :

Statistiques par défaut

On voit que par défaut, perf stat nous donne les mêmes informations que la commande time (wall-clock time, user time, system time), mais qu’en complément il nous donne aussi…

  • Des informations sur l’activité système analogues à celles fournies par GNU time :
    • Changements de contexte
    • Migrations d’une tâche entre coeurs CPU logiques
    • Défauts de page
  • Des informations microarchitecturales sur l’activité CPU :
    • Nombre de cycles CPU écoulés
    • Nombre d’instructions exécutées
    • Nombre de branchements (if, boucles…) effectués
    • Nombre de branchements mal prédits

Par ailleurs, sur la colonne de droite, vous remarquerez que perf stat déduit automatiquement pour vous de ces mesures brutes certaines quantités plus utiles pour une analyse de performances :

  • Les nombres d’événements système sont ramenés à une fréquence par seconde
  • Le temps CPU écoulé est traduit en nombre de CPUs utilisés (en divisant par le temps réel)
  • Les cycles sont traduits en fréquence CPU moyenne (en divisant par le temps CPU)
  • Les instructions sont traduits en instructions par cycle
  • Le nombre de branchements mal prédits est ramené à un taux de branchements mal prédits

Il est utile de connaître la fréquence CPU à laquelle on travaille quand on évalue la performance de son code en termes de limites absolues du système à partir d’un temps d’exécution. Par exemple, ici, notre processeur est capable d’effectuer 2 multiplications flottantes vectorisées par cycle avec une largeur de vecteur de 512 bits, donc à une fréquence d’horloge de 3,905 GHz et en simple précision (32 bits), nous pourrions nous attendre à calculer opérations flottantes par seconde. Mais nous n’en calculons que . Par la suite, nous utiliserons perf pour comprendre pourquoi nous calculons ~8.5x plus lentement que prévu.

Le nombre d’instructions exécutées par cycle est une statistique plus difficile à interpréter, dans la mesure où la valeur optimale dépend du calcul qu’on est en train d’effectuer et du modèle de CPU qu’on utilise. Mais on peut garder en tête quelques ordres de grandeur :

  • Même les unités de calcul les plus sollicitées (chargement depuis la mémoire, ALUs vectorielles…) n’existent qu’en deux exemplaires sur la plupart des CPUs. Donc un code qui atteint >= 2 instructions par cycle est potentiellement limité par les unités de calcul du CPU.
  • Les CPU modernes sont conçus pour effectuer du travail en continu, donc un nombre d’instructions par cycle < 1 (qui signifie que le CPU n’exécute aucune nouvelle instruction durant certains cycles) est suspect et mérite un examen plus approfondi.

Exercice : Essayez de relancer le calcul à nombre de calculs constant, mais avec un tableau de plus en plus grand (par exemple, multipliez le premier paramètre par 10 et divisez le second par 10, plusieurs fois de suite). Observez les changements dans la sortie de perf stat alors que la performance de calcul devient limitée par la bande passante du cache L2, puis celle du cache L3, puis celle de la RAM.

Notez que pour vraiment saturer le cache L3 et la bande passante RAM, vous aurez besoin d’utiliser tous les coeurs CPU. Vous pouvez le faire en ajoutant le paramètre --exclusive à srun, mais comme cela empêche les autres participants du TP de lancer des jobs pendant la durée du vôtre, je vous demanderai d’être particulièrement attentif à ce que vos jobs exclusifs soient de courte durée (quelques secondes).

Pendant qu’on parle de multi-threading, essayez d’ajouter l’option --hint=nomultithread à srun pour désactiver l’hyperthreading. Slurm ne vous allouera alors qu’un seul hyperthread au lieu des deux du coeur CPU que vous obtenez quand vous lancer srun sans argument supplémentaire. Comparez les sorties de perf stat dans ces deux configurations, que constatez-vous ?

Statistiques détaillées

Avec l’option --detailed, qui s’abbrévie en -d, on peut demander à perf stat d’afficher des statistiques plus détaillées incluant l’activité des différents niveaux de cache CPU.

srun --pty \
    perf stat -d \
    ./scale.bin 2048 10000000

Statistiques détaillées

Vous constaterez toutefois qu’en complément de ces nouvelles statistiques (où LLC signifie Last Level Cache, et désigne donc le cache L3 sur les processeurs Intel actuels qui ont trois niveau de cache), des pourcentages entre parenthèses sont apparus à droite de l’affichage.

Ces pourcentages sont la manière de perf de nous avertir que nous sommes un peu gourmands et demandons davantage de données que le CPU n’est capable d’en surveiller à un instant T. Dans ce cas, perf doit simuler un CPU pouvant effectuer toutes les mesures demandées par multiplexage temporel : il alterne entre les différents types de mesures demandés et extrapole ces résultats de mesure partiels en supposant que l’activité du CPU est constante au fil du temps.

Le pourcentage indique pendant quel pourcentage du temps le compteur de performance CPU était réellement actif. Plus il est petit, plus la mesure est extrapolée, donc moins elle est fiable.

Si l’on veut éviter toute extrapolation, on peut demander à perf de ne mesurer que les quantités qui nous intéressent, ici l’activité des cache CPU, en spécifiant les événements souhaités avec l’option --event, qui s’abbrévie en -e.

srun --pty \
    perf stat -e L1-dcache-loads,L1-dcache-load-misses,LLC-loads,LLC-load-misses \
    ./scale.bin 2048 10000000

Statistiques des événements de cache CPU

Exercice : Répétez l’expérience précédente (varier la taille du jeu de données à nombre de calculs constant), en surveillant cette fois l’activité cache.

Les métriques observées au niveau du cache L3 pourraient vous surprendre. Cependant, gardez à l’esprit que comme vous l’avez observé dans l’exercice précédent, la fréquence d’horloge du CPU diminue en situation de stress mémoire, ce qui laisse davantage de temps aux caches pour précharger spontanément des données que vous allez probablement demander par la suite. Dans ce programme simple, la prédiction de vos accès futurs est 100% efficaces.

Pour avoir une vue plus complète de l’activité des caches, vous pouvez utiliser la liste d’événements spécifiques à Intel mem_inst_retired.all_loads,mem_load_retired.l1_hit,mem_load_retired.l1_miss,mem_load_retired.fb_hit,mem_load_retired.l2_hit,mem_load_retired.l2_miss,mem_load_retired.l3_hit,mem_load_retired.l3_miss.

Et pour observer ce qui se change quand vos accès mémoire ne suivent plus une logique prévisible, essayez le programme chase.bin, qui lit les éléments d’un gros tableau dans un ordre aléatoire. Vous aurez probablement envie de diminuer la charge de travail ce faisant, car c’est très inefficace.

Métriques

Nous l’avons déjà mentionné, perf stat est capable de calculer automatiquement certaines métriques de performance à partir des mesures brutes que vous demandez.

Mais il est également possible de prendre le problème dans l’autre sens, et d’indiquer les métriques que vous recherchez en laissant le soin à perf de sélectionner les bons événements matériels à mesurer pour les calculer. Cela se fait avec l’option --metrics, qui s’abbrévie en -M.

Par exemple, si on souhaite tout savoir sur les calculs flottants effectués à chaque cycle CPU, on peut demander la métrique FLOPc (opérations flottants par cycle) :

srun --pty \
    perf stat -M FLOPc \
    ./scale.bin 2048 10000000

Statistiques des opérations flottantes

Le détail des opérations flottantes qui est associé à cette métrique nous permet d’avancer dans la compréhension des faibles performances de notre exemple :

  • Le code n’utilise que des opérations 256-bit, pas les opérations 512-bit natives du matériel. En effet, GCC et clang n’utilisent pas ces dernières par défaut, en raison du trop grand nombre de CPUs Intel où elles sont mal émulées par des unités de calcul 256-bit.
  • Le code n’effectue qu’une opération flottante tous les deux cycles environ (rappelez vous qu’en présence d’hyperthreading, perf compte deux cycles CPU par cycle d’horloge réellement écoulé), alors qu’en principe le matériel est capable de traiter deux opérations par cycle.

Il serait un peu long d’expliquer le second problème avec juste perf stat, nous serons plus productifs avec perf annotate. Mais perf stat suffit pour étudier le premier problème.

Exercice : Recompilez le code en activant la vectorisation 512-bit, ce que vous pouvez faire avec la commande…

rm -f scale.bin && srun make OPTS='-O3 -mprefer-vector-width=512' scale.bin

…puis répétez l’analyse précédente.

Vous noterez que l’accélération dépasse le facteur 2. C’est un phénomène intriguant pour lequel je n’ai pas d’explication. Il semblerait que pour une raison ou pour une autre, le CPU manie plus efficacement les instructions vectorielles de largeur native, et ce en dépit d’une réduction de la fréquence d’horloge que vous pouvez observer avec perf stat sans arguments.

Autres options

Répétition

Quand on mesure des performances logicielles, il est risqué de travailler avec des mesures isolées sans barres d’erreur. On peut très facilement se retrouver à croire voir des différences de performances là où il n’y a que du bruit de mesure.

Pour éviter ce problème, je recommande d’utiliser quand c’est possible l’option --repeat, qui s’abbrévie en -r et demande à perf stat de lancer la commande indiquée plusieurs fois en calculant la moyenne et l’écart-type de chaque métrique affichée :

srun --pty \
    perf stat -r 5 \
    ./scale.bin 2048 10000000

Statistiques répétées

Fenêtre de temps

Sur des programmes plus complexes, on peut aussi se heurter au fait que perf stat n’affiche par défaut que des quantités aggrégées sur l’ensemble de l’exécution du programme, alors que le comportement du programme varie dans le temps. A minima, on a souvent au moins une phase d’initialisation, une phase de calcul, et une phase de finalisation que l’on aimerait bien séparer.

Pour gérer ce genre de problème, perf stat fournit plusieurs options utiles :

  • L’option --interval <msecs> prend en paramètre un nombre de millisecondes, et affiche des statistiques intermédiaires toutes les N millisecondes.
  • L’option --delay <msecs> attend N millisecondes avant de commencer la collecte de données.
  • L’option --timeout <msecs> interrompt la collecte de données au bout de N millisecondes.

En combinant --delay et --timeout, on peut facilement sélectionner une phase de l’exécution du programme pour l’analyser plus précisément, et si jamais le programme n’affiche pas en standard la durée de ses phases d’exécution, l’option --interval peut être utilisée pour l’estimer rapidement sans modifier le code.

Système entier

L’utilisation de perf stat n’est pas restreinte au suivi de l’utilisation des ressources par une commande. On peut suivre toute l’activité système avec l’option --all-cpus, abbréviée en -a, qui est activée par défaut si l’on invoque perf stat sans lui donner une commande en argument.

Dans le cadre de srv-calcul-ambulant, ce périmètre est généralement trop large et on souhaitera ne surveiller que l’activité de certains coeurs CPU, ce qu’on peut faire avec l’option --cpu, qui s’abbrévie en -C.

Nous pouvons par exemple utiliser cela pour comparer l’activité des coeurs CPU interactifs à celle des dédiés au benchmark pendant qu’un moniteur système s’exécute sur une session interactive…

# Dans un shell
dstat-summary

# Dans un autre shell
perf stat --timeout 2000 -r 5 -C 0,1,18,19
perf stat --timeout 2000 -r 5 -C 2-17,20-35

Statistiques coeurs interactifs Statistiques coeurs de benchmark

On notera que certaines des métriques de perf sont à interpréter avec précautions dans ce mode (par exemple la fréquence d’horloge CPU moyenne en présence de veille intermittente). Toutefois, l’information essentielle reste : bien qu’étant 8x plus nombreux, les coeurs de benchmark exécutent ~6x moins d’instructions dans ce test simple, ou ~50x moins d’instructions par coeur.

Aide intégrée

Il existe beaucoup d’autres options de perf stat, permettant entre autres choses de suivre l’activité d’un processus en cours d’exécution (option -p <PID>) et dans le cas d’un suivi à l’échelle du système entier de ségréréger les données mesurées par coeur CPU, socket, noeud NUMA…

Pour en savoir plus sur ces fonctionnalités plus avancées, vous pouvez consulter l’aide intégrée de perf stat avec la commande perf help stat.

perf annotate

Dans la première partie sur perf stat, nous avons vu comment perf nous permet de compter divers événements matériels et système.

Dans cette seconde partie, nous allons maintenant aller plus loin en corrélant la survenue de ces événements avec les actions du programme, ce qui nous permettra de repérer des “points chauds” du code pour cibler notre effort d’optimisation sur ceux-ci.

Échantillonnage

Avant d’étudier perf annotate, ou les autres outils basés sur perf record, il faut avoir bien compris la notion théorique de profilage par échantillonnage.

Lorsqu’on étudie la performance d’un programme, il est important de le faire de façon aussi peu intrusive que possible. Sinon, on risque de mesurer les caractéristiques de performances de son outil de mesure plutôt que celles du programme qui nous intéresse.

De ce point de vue, il n’est pas souhaitable de mesurer la façon dont un programme utilise ses cycles CPU en analysant la position du pointeur d’instruction (= le point du programme où on se trouve) à chaque cycle CPU. En effet, analyser la position du pointeur d’instruction (ou même juste l’écrire quelque part) demande plusieurs cycles CPU, pendant lesquels le programme ne pourrait pas avancer sous peine de devancer le profileur. Donc cela conduirait à ralentir démesurément l’exécution du programme, en nous éloignant d’autant d’une exécution réaliste. Ainsi, un programme limité par les entrées-sorties “dans la vraie vie” pourrait apparaître limité par la vitesse d’exécution de code dans les mesures.

De même, quand on s’intéresse à l’utilisation du cache CPU, il ne serait pas souhaitable d’enregistrer des informations à propos de chaque défaut de cache L1, car cela arrive aussi bien trop souvent.

A la place, tous les profileurs modernes utilisent une approche statistique appelée échantillonnage, où l’on n’enregistre qu’un sous-ensemble des événements qui nous intéressent (N événements par seconde, ou bien 1 événement tous les N événements), appelés échantillons.

L’idée de cette approche est que si notre jeu d’échantillons est représentatif et de taille suffisante, nous pouvons en conclure à peu près les mêmes choses que nous aurions conclues si nous avions eu le loisir d’enregistrer et analyser la totalité des événements. Par exemple, si nous avons mesuré un grand nombre d’échantillons et 20% d’entre eux tombent dans une certaine partie du code, alors nous pouvons supposer que si nous avions pu mesurer la totalité des événements étudiés, environ 20% se seraient produits dans cette même partie du code.

Une originalité de perf par rapport à d’autres profileurs fonctionnant par échantillonnage est que ce profileur n’est pas seulement capable de mesurer quelles sont les parties du code où l’on passe beaucoup de cycles CPU, mais plus généralement quelles sont les parties du code qui sont corrélées à toutes sortes d’événements système : défauts de cache, écritures disque, etc.

Code annoté

Pour enregistrer un profil par échantillons avec perf, on utilise la commande perf record.

Dans sa configuration par défaut, perf record enregistre le pointeur d’instruction à intervalle de temps régulier (plus précisément à intervalle de cycles CPU régulier). Cela permet d’analyser dans quelles parties du code l’on passe le plus de temps. Essayons :

srun --pty \
    perf record \
    ./scale.bin 2048 10000000

Sortie de perf record

Vous noterez que contrairement à perf stat, perf record n’émet aucun résultat de mesure en sortie, il mentionne juste que des données ont été écrites dans un fichier appelé perf.data.

Nous pouvons ensuite afficher l’assembleur du programme, avec des annotations indiquant la proportion du temps passé à exécuter chaque instruction, en utilisant perf annotate

perf annotate

Sortie de perf annotate

…et nous constatons que sur les 814 lignes que produit le désassembleur objdump -d pour ce programme, 92% du temps CPU est passé à exécuter quatre instructions assembleur. Il s’agit de notre fond de boucle de multiplication de flottants, comme le clarifie la ligne de code source associée. perf a pu retrouver le code source associé à l’assembleur grâce aux symboles de déboguage, le programme ayant été compilé avec l’option -g.

Par défaut, perf annotate place le curseur sur l’instruction assembleur la plus “chaude” du point de vue de l’événement considéré (ici le passage des cycles processeurs). On peut aller vers les instructions de moins en moins “chaudes” en utilisant TAB (avec retour en arrière via Maj+TAB).

Une autre commande clavier à connaître est la touche t, qui permet de basculer de l’affichage par défaut indiquant le pourcentage des échantillons correspondant à chaque ligne de code à d’autres affichags décrivant les données brutes. Parmi ces affichages, l’affichage “Samples” indique le nombre d’échantillons mesurés sur chaque ligne de code, ce qui permet de s’assurer qu’on n’est pas en train de tirer des conclusions hâtives d’une mesure non statistiquement significative.

Deux autres touches qu’il est utile de connaître sont la touche q qui permet de quitter l’interface perf annotate, et la touche h qui permet d’afficher l’aide des commandes claviers.

Si l’on place le curseur sur une instruction de saut, perf annotate va indiquer la cible du saut via une flèche, ainsi que la condition associée si il s’agit d’un saut conditionnel. Si l’on appuie ensuite sur Entrée, le curseur sera déplacé à la cible du saut. Nous retrouvons ainsi notre boucle de calcul :

Sauts dans perf annotate

Et avec ce zoom sur l’assembleur qui limite les performances nous pouvons maintenant répondre à une des questions de performances que nous nous posions précédemment : pourquoi ce programme ne fait-il qu’une multiplication vectorisée tous les deux cycles ?

Pour le profane de l’assembleur, ce code stocke…

  • La base du tableau “input” dans le registre rdx
  • La base du tableau “output” dans le registre rax
  • Le multiplicateur 4.2f, répété pour remplir un registre vectoriel, dans zmm2
  • Le compteur de boucle (en octets) dans le registre rcx
  • La taille de tableau (en octets) dans le registre rdi

A chaque itération, il…

  • Charge un paquet de flottants depuis la position actuelle du tableau “input”…
    • …le multiplie par notre paquet de multiplicateurs zmm2
    • …et stocke le résultat dans zmm0
  • Stocke le contenu de zmm0 à la position actuelle du tableau “output”.
  • Incrémente le compteur de boucle.
  • Regarde si on est arrivé à la fin des tableaux.
  • Sinon, recommence du début

Ces opérations sont, en principe, exécutables en parallèle par un CPU moderne utilisant des mécanismes de pipelining, prédiction de branchement et spéculation. En simplifiant, un CPU peut, pendant les premiers cycles…

  1. Charger un 1er paquet de flottants, calculer le 2e compteur de boucle, spéculer que la boucle va continuer pour une 2e itération.
  2. Charger un 2e paquet de flottants, multiplier les 1ers flottants par le multiplicateur, calculer le 3e compteur de boucle, spéculer que la boucle va continuer pour une 3e itération, vérifier que la spéculation sur le 2e compteur de boucle était correcte.
  3. Charger un 3e paquet de flottants, multiplier les 2èmes flottants par le multiplicateur, stocker les 1ers résultats, calculer le 4e compteur de boucle, spéculer que la boucle va continuer pour une 4e itération, vérifier que la spéculation sur le 3e compteur de boucle était correcte..
  4. …et ainsi de suite en régime établi…

Mais il y a toutes sortes de limites pratiques à ce parallélisme d’instructions. L’une d’elle est que notre CPU (Intel i9-10980XE) ne peut pas traiter de façon pérenne plus de 4 opérations élémentaires par cycle, où dans notre exemple, les opérations élémentaires sont…

  • Une opération mémoire (lecture ou écriture)
  • Une operation arithmétique (vectorielle ou scalaire)
  • La paire d’opérations comparaison + saut conditionnel

Pour plus de détails sur ce point, vous pourrez consulter le manuel de microarchitecture d’Agner Fog ainsi que l’entrée de Wikichip concernant la microarchitecture Skylake après le TP.

Toujours est-il que nous dépassons ici cette limite de 4 opérations par cycle avant d’avoir atteint la limite de performances arithmétique de 2 multiplications par cycle à cause des opérations de gestion du compteur de boucle. Heureusement, ce n’est pas une fatalité, et nous pouvons amortir le coût de ces opérations en déroulant la boucle, c’est à dire en faisant plusieurs multiplications vectorielles par saut au niveau de l’assembleur.

Pour une raison inconnue, GCC n’a pas effectué cette optimisation spontanément ici, mais on peut l’encourager avec l’option de compilation -funroll-loops.

Exercice : Recompilez le programme avec cette option, tout en conservant la vectorisation 512-bits, ce que vous pouvez faire avec la commande…

rm -f scale.bin \
&& srun make OPTS='-O3 -mprefer-vector-width=512 -funroll-loops' scale.bin

…puis analysez l’effet sur l’assembleur généré et les performances avec perf stat et perf annotate.

Aller plus loin

Conclusions sur le premier exemple

Au terme de l’exercice qui précède, vous devriez être arrivé à une performance de calcul d’un peu moins d’une multiplication 512 bits par cycle.

Un peu moins car à l’heure où ces lignes sont écrites, sur srv-calcul-ambulant, on calcule en réalité deux multiplications en trois cycles. Cela est dû à un problème d’exécution superscalaire appelé “4K aliasing”, dont vous pouvez surveiller l’émergence avec le compteur de performance Intel ld_blocks_partial.address_alias, à considérer en proportion du compteur de chargements mémoire mem_inst_retired.all_loads.

Pour mieux comprendre ce qui se passe, considérons la partie chaude de notre boucle de calcul tel que le CPU la voit, donc au niveau de l’assembleur généré :

vmulps (%rdi,%rax,1),%zmm0,%zmm10
vmovups %zmm10,(%r15,%rax,1)
vmulps 0x40(%rdi,%rax,1),%zmm0,%zmm11
vmovups %zmm11,0x40(%r15,%rax,1)
vmulps 0x80(%rdi,%rax,1),%zmm0,%zmm12
vmovups %zmm12,0x80(%r15,%rax,1)
vmulps 0xc0(%rdi,%rax,1),%zmm0,%zmm13
vmovups %zmm13,0xc0(%r15,%rax,1)
vmulps 0x100(%rdi,%rax,1),%zmm0,%zmm14
vmovups %zmm14,0x100(%r15,%rax,1)
vmulps 0x140(%rdi,%rax,1),%zmm0,%zmm15
vmovups %zmm15,0x140(%r15,%rax,1)
vmulps 0x180(%rdi,%rax,1),%zmm0,%zmm2
vmovups %zmm2,0x180(%r15,%rax,1)
vmulps 0x1c0(%rdi,%rax,1),%zmm0,%zmm4
vmovups %zmm4,0x1c0(%r15,%rax,1)
add    $0x200,%rax
cmp    %rax,%r8
jne    401918 <main._omp_fn.0+0x438>

A chaque étape du calcul, nous lisons des données depuis une adresse mémoire, calculons des résultats, et stockons les résultats à une autre adresse mémoire. Et pour travailler de façon efficace, le CPU voudrait exécuter l’ensemble de ces opérations en parallèle avec une organisation en pipeline : à chaque cycle d’horloge, il doit pouvoir simultanément lire des données pour une itération de boucle N, faire le calcul pour l’itération précédente N-1, et stocker le résultat de l’itération N-2.

Mais ce parallélisme d’instructions opère sous une contrainte importante : il doit produire le même résultat que si le programme avait été exécuté séquentiellement, une instruction à la fois. Et ce même dans le cas tordu où on spécifierait des tableaux d’entrée et de sortie qui se recouvrent.

Dans cette situation, si l’adresse d’écriture à l’itération N, que nous noterons , correspond à une adresse de lecture pour une itération M > N future de la boucle de calcul, alors cette itération M de la boucle devra attendre que le résultat de l’itération N soit prêt pour s’exécuter. Ce n’est pas un problème si M est beaucoup plus grand que N, car le résultat de l’itération N sera prêt au moment où l’itération M en aura besoin. Mais c’est un problème si M est juste un peu plus grand que N, car cela crée une chaîne de dépendances qui empêche l’exécution parallèle.

Plus formellement, dans notre cas où nous faisons des écritures et lectures mémoire de même taille et bien alignées, ce qui permet d’écarter la question des recouvrements partiel, et où nous avons un pipeline de calcul de longueur 3 (lecture, multiplication, écriture), il y a un problème quand on se retrouve dans la situation avec petit.

Très bien, me direz-vous, mais pourquoi est-ce que je vous parle de ce cas tordu alors qu’il ne nous concerne clairement pas ici, vu que nous écrivons du code civilisé où les tableaux d’entrée et de sortie ne se recouvrent pas ? Eh bien parce que le CPU ne peut pas malheureusement pas vérifier cette propriété de façon exacte.

En effet, les CPU x86 disposent d’un mécanisme de mémoire virtuelle qui permet à deux pointeurs de valeurs différentes de correspondre à la même adresse physique en RAM. Pour éliminer cette possibilité, il faudrait consulter la table des traductions d’adresse, et cela prendrait bien trop de temps pour notre pauvre ordonnanceur superscalaire qui doit donner son verdict en quelques cycles. Une approximation est donc nécessaire ici.

L’approximation qui est faite, c’est de n’utiliser pour étudier la possibilité d’un recouvrement que les 12 bits de poids faible des adresses mémoire concernées, ceux-ci n’étant pas affectés par le processus de traduction d’adresse sous architecture x86 (qui travaille par blocs alignés de 4 Ko).

Nous ne voulons donc pas seulement avoir . Nous voulons en fait avoir avec aussi petit que possible sans être nul. Comme nous travaillons en AVX-512, où nos accès mémoire se font par blocs consécutifs de 64 octets, cela revient à dire qu’on veut avoir…

Il n’est pas facile de garantir ce genre de propriété sur les adresses mémoires de nos tableaux d’entrée et de sortie quand ils s’agit de deux allocations mémoire séparées dont le placement en mémoire est laissé à la discrétion de l’implémentation de la bibliothèque standard C. Mais on peut se placer dans cette situation en n’allouant qu’un seul gros tableau dont le début servira pour les sorties et la fin pour les entrées, avec un petit peu de padding au milieu pour atteindre l’espacement souhaité entre données d’entrée et de sortie :

const size_t aliasing_stride = 4096 / sizeof(float);
const size_t natural_alignment = amount % aliasing_stride;
const size_t desired_alignment = 192 / sizeof(float);
const size_t padding = (natural_alignment < desired_alignment)
                     ? desired_alignment - natural_alignment
                     : aliasing_stride - natural_alignment + desired_alignment;
//
simd_friendly_vector<float> buffer(2*amount+padding, 0.0);
float* const output = buffer.data();
const float* const input = output + amount + padding;
for (size_t iter = 0; iter < iterations; ++iter) {
    assumeAccessed(input);
    for (size_t i = 0; i < amount; ++i) {
        output[i] = input[i] * 4.2f;
    }
    assumeAccessed(output);
}

Et ceci étant fait, on arrive bien à une multiplication 512-bit par cycle, ce qui est déjà nettement mieux que notre point de départ. Mais au début du TP, j’avais mentionné que notre processeur peut effectuer deux multiplications par cycle. Il nous reste donc encore un facteur 2 de puissance de calcul flottante au niveau du CPU qui n’est pas exploitée par ce programme.

La raison est que notre processeur Cascade Lake ne peut faire qu’une écriture mémoire par cycle processeur. Or nous effectuons une écriture mémoire pour chaque calcul que nous faisons. Nous ne pouvons donc pas effectuer plus d’un calcul par cycle pour ce benchmark.

Donc si on veut des performances de calcul optimales de façon portable, une des contraintes qu’on doit respecter est de faire davantage de calculs par écriture en mémoire.

Pour une petit liste d’autres limites possible à la vitesse d’exécution d’un programme calculatoire, vous pourrez consulter https://travisdowns.github.io/blog/2019/06/11/speed-limits.html après le TP.

Exercice avancé

Si vous êtes en avance et êtes plutôt à l’aise en lecture d’assembleur x86, voici un second exercice qui vous permettra de mieux apprécier les possibilités offertes par perf annotate.

Le programme sum.cpp, qui calcule la somme des éléments d’un tableau de nombres flottants, n’est pas limité par la performance des écritures mémoire. On s’attendrait donc à ce qu’il atteigne une performance 2x plus importante que scale.cpp une fois pleinement optimisé.

Toutefois, dans son état actuel, ce programme s’exécute un peu moins de 13x plus lentement que la version initiale de scale.cpp !

Si l’on passe l’option -ffast-math à GCC, qui autorise des optimisations numériquement instables, le programme s’exécute plus rapidement. Mais pas encore aux performances crêtes du matériel.

Avec l’aide des outils que nous avons vus jusqu’à présent, essayez de comprendre par vous-même…

  • Pourquoi les performances du programme de base sont aussi mauvaises.
  • Quels changements surviennent dans le code généré lorsque l’option -ffast-math est passée à GCC, et pour quelle raison ces optimisations ne sont pas permises sans cette option.

Ayant éclairci ces points, vous pouvez ensuite essayer de terminer le travail d’optimisation incomplètement effectué par GCC en atteignant les performances crêtes du matériel. Pour cela, vous aurez besoin des deux informations microarchitecturales suivantes :

  • Notre microarchitecture CPU ne peut effectuer qu’un seul saut par cycle.
  • Deux opérations arithmétiques qui dépendent les unes des autres (ex : le résultat de la première opération est passé en paramètre de la seconde opération) ne peuvent pas être s’exécuter en parallèle.

Si vous souhaitez “pimenter” un peu l’exercice, essayez d’atteindre les performances crêtes du matériel sans avoir recours ni à -ffast-math, ni à des intrinsèques spécifiques à l’architecture x86.

Autres options

perf annotate a relativement peu d’options intéressantes, on notera surtout quelques options de filtrage qui peuvent parfois être utiles comme…

  • --dsos/-d qui permet de n’afficher que le source d’un binaire sur des programmes composés de plusieurs binaires liés dynamiquement
  • --symbol/-s qui permet de n’afficher qu’une seule fonction (symbole) du binaire cible
  • --cpu/-C qui permet de n’afficher que l’activité de certains coeurs CPU

En revanche, perf record possède un grand nombre d’options qui sont souvent utiles. Nous n’aborderons certaines que dans la partie sur perf report, où leur fonction sera plus claire, mais nous pouvons déjà en mentionner quelques unes ici.

Similarités avec perf stat

Tout d’abord, un grand nombre d’options que nous avons déjà abordées dans le cadre de perf stat restent utilisables avec perf record. C’est ainsi le cas de --event/-e, --all-cpus/-a, --pid/-p, --cpu/-C et --delay/-D. Comme nous avons déjà abordé ces options dans la partie sur perf stat, nous ne reviendrons pas dessus ici.

Fréquence d’échantillonage

L’un des plus importants compromis de configuration d’un profileur par échantillonnage est la fréquence avec laquelle il mesure des échantillons, sa fréquence d’échantillonage :

  • Plus l’on mesure des échantillons fréquemment, plus la mesure est précise à temps de mesure égal, mais plus l’on biaise le profil de performance du programme étudié en consommant des ressources systèmes au niveau du profileur.
  • La fréquence d’échantillonage ne doit pas être multiple d’une fréquence temporelle caractéristique à laquelle le programme effectue des tâches, sous peine de sur ou sous-évaluer aléatoirement le poids de ces tâches dans l’exécution du programme.

Comme le coût de mesure d’un échantillon peut varier grandement en fonction de ce qu’on mesure, il est préférable d’ajuster empiriquement la fréquence d’échantillonnage. Un moyen de le faire est de mesurer une caractéristique de performance simple du programme (par exemple le temps d’exécution) avec et sans instrumentation perf record, et de réduire la fréquence d’échantillonnage de perf record jusqu’à ce que l’on n’observe plus de dégradation de performance mesurable.

Perf permet d’ajuster la fréquence d’échantillonnage selon deux critères :

  • Mesurer 1 événement tous les N événements, via l’option --count N qui s’abbrévie en -c N. C’est en général le levier nativement utilisé par l’implémentation de perf sous le capot. Son principal défaut est que le surcoût associé à la mesure dépend de la fréquence de l’événement, qui n’est a priori pas connue à l’avance et variable dans le temps.
  • Viser une fréquence d’échantillonnage moyenne de M échantillons par seconde, via l’option --freq M qui s’abbrévie en -F M. Cette option offre des surcoûts de mesure mieux contrôlés, et est donc généralement préférable.

Pour ne pas bloquer le système, perf s’impose une limite de fréquence d’échantillonnage. Si l’on demande une fréquence plus élevée, la demande sera ignorée. On peut demander à perf d’opérer à cette fréquence maximale avec -F max. Cette limite est configurable, comme expliqué dans l’annexe sur l’installation de perf.

Fichier de sortie

perf record produit par défaut un fichier de données dans le répertoire courant, ce qui occasionne logiquement des écritures sur le périphérique associé. Cela peut être problématique dans deux cas :

  • Quand on analyse les performances d’un programme qui utilise le même périphérique, car l’activité de stockage associée à perf va se mélanger à celle du programme étudié et rendre les mesures plus difficiles à interpréter.
  • Quand on pousse perf dans ses retranchements et lui fait générer un grand flux de données, plus intense que le périphérique de stockage sous jacent ne peut absorber.

Pour gérer ces cas, on peut indiquer à perf record d’écrire ses données à un autre endroit avec l’option --output /chemin/vers/le/fichier, qui s’abbrévie en -o /chemin/vers/le/fichier. Cette option doit être couplée à une option --input équivalente dans les commandes qui analysent la sortie de perf record, comme perf annotate.

En particulier, une astuce courante est d’écrire les données dans un ramdisk, répertoire virtuel hébergé en RAM et qui dispose donc d’une bande passante très importante que le périphérique de stockage. En fin de mesure, on ramènera ensuite les données dans le répertoire courant pour ne pas encombrer inutilement la RAM et risquer de perdre ses données en cas d’arrêt de la machine.

Sur srv-calcul-ambulant, on peut le faire comme ceci :

srun --pty \
    perf record -o /dev/shm/${USER}/perf.data  ... autres options ... \
&& mv /dev/shm/${USER}/perf.data .

perf list

Dans les premières parties, vous avez travaillé pour faire simple avec des événements choisis automatiquement par perf ou donnés par l’énoncé du TP.

Dans cette partie, nous allons utiliser la commande perf list pour explorer la liste complète des événements système prédéfinis par perf.

Événements prédéfinis

Pour avoir la liste complète des événements système prédéfinis par perf, utilisables via l’option --event (qui s’abbrévie en -e) de toutes les commandes perf qui font un suivi d’événements, vous pouvez utiliser la commande perf list.

Mais comme vous allez rapidement le constater si vous essayez, cette liste est très longue (5933 lignes au moment où j’écris ce TP). Il est donc utile de connaître ses mécanismes de filtrage.

Tout d’abord, les événements sont triés en grandes catégories, que j’aurais personnellement tendance à regrouper encore en catégories de niveau supérieur :

  • Avec perf list hw cache, vous pouvez afficher la liste des événements matériels génériques/abstraits. Ces événements sont mesurables sur à peu près tout matériel supporté par perf, et implémentés en utilisant les événements spécifiques à chaque matériel.
  • Avec perf list sw tracepoint sdt, vous pouvez afficher la liste des événements logiciels, qui sont générés par le noyau Linux et certaines bibliothèques. Au niveau des sous-catégories…
    • Ceux libellés [Software event] et [Tool event] sont accessibles sur tout ordinateur équipé de perf sans configuration supplémentaire.
    • Ceux libellés [Tracepoint event] et [SDT event] nécessitent des privilèges de traçage (voir l’annexe installation pour plus d’informations sur ce point), qui ont été préconfigurés pour vous sur srv-calcul-ambulant.
      • Ceux libellés [SDT event] nécessitent également des préparatifs avant utilisation, que nous aborderons dans la partie sur perf probe.
  • Avec perf list pmu, vous pouvez afficher la liste des événements que votre CPU sait compter via sa Performance Monitoring Unit (PMU). Ils sont très nombreux, particulièrement chez Intel, c’est la majeure partie de la sortie brute de perf list sans filtrage.
  • Avec perf list metric metricgroup, vous pouvez afficher la liste des métriques et groupes de métriques. Mais curieusement, cette liste n’est pas hiérarchisée comme la sortie finale de perf list, ce qui la rend moins lisible. Je vous recommande donc plutôt d’utiliser perf list sans arguments ici, en sautant à la fin du texte avec la touche Fin de votre clavier.

Il est aussi possible d’effectuer une recherche au sein de la sortie de perf list, par exemple perf list float retournera tous les événements dont le nom ou la description contient le mot “float”.

Exercice : Quand on vectorise du code sans revoir sa politique d’allocation mémoire, on se retrouve fréquemment avec des problèmes de performances liés à des accès mémoire non alignés.

Contrairement à une croyance populaire tenace, les mauvaises performances observées ne proviennent pas du fait que le compilateur génère des instructions non optimisées pour les accès alignés comme movups. Sur les CPUs actuels, ces instructions ont des performances équivalentes à leurs cousines optimisées pour les accès alignés, qui sont donc plus ou moins obsolètes.

En réalité, le vrai problème est que dans cette configuration, les accès mémoire liés au calcul vectorisé, qui sont très larges, peuvent et vont souvent se retrouver à cheval sur deux lignes de cache, ce qui force le processeur à lire ou écrire deux lignes de cache au lieu d’une. Il en résulte une sur-utilisation des ressources mémoire, qui cause le ralentissement observé.

Dans la section “cache” de la sortie de perf list pmu, trouvez quels événements passer en paramètre à perf stat -e pour établir avec certitude si vous avez ou non affaire à ce problème.

Modificateurs

Il est possible d’appliquer un certain nombre de contraintes à la façon dont perf enregistre des événements, par le biais de suffixes placés à la fin du nom de l’événement, après un signe : (par exemple L1-dcache-load-misses:u).

  • Le suffixe k permet de ne compter que les événements survenus pendant l’exécution du code du noyau Linux, et de façon symétrique le suffixe u permet de ne compter que les événements survenus hors du noyau (en user-space).
  • Le suffixe p peut être spécifié une ou plusieurs fois, pour indiquer avec quelle précision on souhaite que les événements soient localisés dans le code source du programme. Ceci concerne les mesures à base de PMU, pour lesquels une localisation parfaite des événements dans le code source peut être onéreuse voire impossible au niveau matériel à cause du parallélisme d’instructions.
    • Si ce suffixe n’est pas précisé, les événements peuvent être détectés à une distance arbitraire et variable du point où le programme se trouvait réellement quand ils se sont produit. L’interprétation de l’assembleur annoté (perf annotate) et du profil de fonctions courtes doit dans ce cas être effectuée avec de grandes précautions !
    • Si il est précisé une fois (comme dans L1-dcache-load-misses:p), les événements peuvent être détectés à distance du point du code source dont ils sont originaires, mais avec un décalage en instructions constant. Il suffit donc de trouver quel est ce décalage pour interpréter correctement l’assembleur annoté.
    • Si il est précisé deux fois (comme dans L1-dcache-load-misses:pp), perf demande en plus au CPU de viser un décalage en instructions nul. La demande sera ignorée si le CPU ne peut pas la satisfaire.
    • Si il est précisé trois fois (comme dans L1-dcache-load-misses:ppp), le CPU émulera un décalage en instructions nul si nécessaire en randomisant la position détectée du pointeur d’instruction. Attention, ce mode nécessite davantage de travail que le mode :pp du côté perf (il faut donc revoir les fréquences d’échantillonage à la baisse), et il n’est pas disponible pour tous les compteurs ni tous les fabricants de CPUs.
  • Le suffixe P représente vers la forme la plus précise du suffixe p disponible sur le CPU hôte.

Motifs wildcards

Les événements de type [Tracepoint event] (et seulement eux) supportent le motif wildcard * avec les mêmes sémantiques que bash. On peut demander à une commande comme perf stat de mesurer tous les événements qui respectent un tel motif.

Par exemple, on peut surveiller pendant 10s les appels système ayant trait à l’horloge du noyau…

perf stat --timeout 10000 -e syscalls:*clock*

Appels systèmes liés à l’horloge

…et l’on constatera que l’usage le plus fréquente que les applications font de l’horloge système est de loin d’attendre qu’une certaine durée soit écoulée.

Exercice : Selon le même principe, mesurez quels sont les tracepoint liés au réseau (groupe net:) qui sont visités le plus fréquemment.

perf trace

Dans la partie précédente, nous avons rappelé que perf est capable de mesurer diverses formes d’activité du noyau Linux, et montré comment compter ces événements avec perf stat.

Toutefois, quand on analyse des composants logiciels complexes, un simple décompte d’événements ne suffit pas toujours. On veut souvent connaître d’autres choses comme…

  • Les informations qui passées en paramètre d’une API, ou retournées par celle-ci.
  • L’enchaînement temporel des opérations étudiées.

perf trace est un outil qui aide à répondre ce type de questions.

Commande unique

Une première utilisation possible de perf trace est de surveiller les appels systèmes effectués par une commande, à la manière de l’utilitaire Unix traditionnel strace :

srun --pty \
    perf trace \
    ./scale.bin 2048 10000000
Sortie de perf trace

Pour comparaison, voici la sortie de strace sur ce même programme :

Sortie de strace

Que peut-on conclure de cette première observation ?

On constate tout d’abord que même l’exécution d’un “pur calcul” nécessite des appels systèmes, liés au chargement des bibliothèques partagées et à diverses étapes d’initialisation.

On remarquera aussi que la sortie de perf trace est moins verbeuse que celle de strace, car elle omet certains détails comme les nombreuses étapes de recherche des bibliothèques partagées. Selon ce que l’on essaie de faire, cela peut être un avantage ou un inconvénient.

On notera enfin que perf trace est dans l’ensemble un outil moins mature que strace, et qu’il lui manque notamment cruellement la capacité de décoder les arguments de type chaîne de caractère.

Mais ce qui est moins visible, c’est que l’implémentation de ces deux outils est aussi très différente :

  • strace fonctionne comme un débogueur : il demande au noyau Linux d’interrompre le programme et lui donner la main à chaque fois qu’un appel système survient, affiche les caractéristiques de l’appel système observé, puis fait redémarrer le programme.
  • perf trace n’interrompt pas l’exécution du programme, il demande plutôt au noyau de lui envoyer une notification chaque fois qu’un appel système survient et affiche ensuite les événements dont il a été notifié de façon asynchrone.

Ces différences d’implémentation ont plusieurs conséquences :

  • perf trace a un impact bien moins élevé sur les performances du programme étudié.
  • La sortie de perf trace n’est pas synchronisée avec celle du programme étudié, ce qui peut parfois la rendre plus difficile à interpréter.
  • Si le programme effectue des appels système plus vite que perf trace ne parvient à les analyser, perf trace ne parviendra pas à traiter tous les appels système.
  • perf trace n’est pas restreint à la surveillance des seuls appels système et peut surveiller l’activité du système entier, intérieur du noyau Linux compris.

Exercice : Comparez perf trace et strace en termes de comportement et d’impact sur les performances de la commande tree /usr >/dev/null, qui énumère l’ensemble des fichiers du dossier /usr et effectue pour cela un très grand nombre d’appels système. Voici une commande Slurm que vous pouvez utiliser comme base de travail :

srun --pty \
    bash -c 'time tree /usr >/dev/null'

N’oubliez pas de lancer la commande une première fois avant les mesures pour pré-remplir le cache disque. Du fait de l’existence de ce cache, la première exécution aura des caractéristiques de performances différentes des suivantes…

Si vous appliquez strace au processus bash plutôt qu’au processus tree, vous aurez besoin de l’option -f qui permet de suivre les processus enfants. perf trace effectue ce suivi par défaut.

Système entier

Avertissement : La version de perf actuellement installée sur srv-calcul-ambulant a des difficultés à nommer les threads secondaires du démon slurmctld, qui sont juste identifiés par un identifiant numérique du genre :2301. Il faut lancer perf trace en tant qu’administrateur pour que les noms soient résolus correctement. Ce comportement n’était pas observé avec d’anciennes versions de perf, et il est possible qu’il disparaisse à nouveau à l’avenir, mais en attendant, je garde les captures d’écran de l’ancienne version, qui sont plus informatives. Donc ne vous étonnez pas si vos propres exécutions de perf stat ont des sorties texte différentes et moins claires.

Introduction

Nous l’avons mentionné précédemment, perf trace ne représente pas une avancée majeure par rapport à strace pour suivre les appels système d’un processus unique. Son intérêt principal par rapport à strace est de donner accès à des informations inaccessibles via ce dernier :

  • L’activité du système entier
  • L’activité noyau à grain plus fin que les appels système

Commençons par le suivi de l’activité du système entier. Celui-ci, qui peut être déclenché avec le flag --all-cpus, qui s’abbrévie en -a, peut être riche en enseignement. Mais il y a certaines précautions à prendre quand on a recours à cette approche.

Pour comprendre pourquoi, essayons d’abord de le faire naïvement pendant un bref instant. Notez au passage l’utilisation d’une commande sleep pour limiter la durée du suivi, cette astuce est applicable à de nombreuses commandes perf.

srun --pty \
    perf trace -a \
    sleep 0.001
Sortie de perf trace

Vous ne vous attendiez peut être pas à une sortie aussi volumineuse en surveillant l’activité d’un système Linux minimaliste pendant une milliseconde. Et en effet, comme ma formulation précédente vous l’aura fait deviner, quelque chose s’est mal passé.

Un examen rapide de la sortie de perf trace permettra de comprendre la nature du problème. En agissant trop naïvement, nous avons violé une règle d’or du suivi de l’activité système :

Tu ne provoqueras pas, une fois par événement système surveillé, un événement système que tu surveilles également.

Ici, à chaque fois qu’un appel système est observé, perf trace écrit une sortie dans la console. Cette écriture console nécessite un appel système, lui-même détecté par perf trace, ce qui déclenche une autre écriture console, et donc un autre appel système… Une boucle infinie s’amorce, générant un énorme volume d’activité système qui masquera tout ce qu’on aurait pu vouloir observer d’autre que l’activité de perf.

Vous devrez garder en tête ce principe à chaque fois que vous utiliserez les fonctionnalités de perf pour surveiller l’activité système de façon exhaustive. Le piège ci-dessus peut vous sembler grossier après coup, mais parfois il sera plus subtil, par exemple si vous surveillez l’activité disque ou système de fichier avec une commande comme perf record qui génère de l’activité disque.

Mais dans notre cas simple d’un suivi global des appels système, perf trace fournit plusieurs manières de contourner le problème.

Approche par résumé et détails ciblés

Une première approche est de commencer par demander un résumé des appels système observés pendant une certaine durée avec l’option --summary, qui s’abbrévie en -s et désactive la sortie “live” de perf trace

srun --pty \
    perf trace -a -s \
    sleep 5
Sortie de perf trace

…puis de relancer perf trace en ne suivant que les appels système qui, d’après le résumé, sont appelés particulièrement souvent ou bloquent l’application pour une durée particulièrement longue. On peut effectuer ce filtrage avec l’habituelle option --event/-e :

srun --pty \
    perf trace -a -e futex,clock_nanosleep,epoll_wait \
    sleep 3
Sortie de perf trace

Nous remarquons ainsi plusieurs choses intéressantes :

  • Le démon slurmctld (un des composants de l’implémentation de Slurm) semble avoir une stratégie de synchronisation basée sur l’attente active, car il fait des clock_nanosleep de 100ms avec une régularité de métronome, et ne fait aucun autre appel système aussi fréquemment. Il est probable qu’à cet intervalle de temps, il communique avec d’autres composants de Slurm par un canal autre que le noyau Linux, peut-être des opérations atomiques en mémoire partagée.
  • Le démon sstate (un autre composant de Slurm) semble utiliser une stratégie extrêmement agressive pour synchroniser ses threads. En effet, toutes les secondes, il fait des salves extrêmement rapides et prolongées d’appels à futex(), la brique de base d’implémentation des primitives de synchronisation pthread sous Linux. Deux futex sont utilisés:
    • L’un d’eux, situé à l’adresse mémoire 0x55558d761da0, est utilisé pour réveiller un thread toutes les 20µs. Cette fréquence semble étonamment élevée.
    • L’autre, situé à l’adresse mémoire 0x55558d761d8c, est utilisé pour attendre une notification qui ne vient pas, encore une fois toutes les 20µs. Il semblerait qu’un timeout soit utilisé, mais vu la fréquence d’appel celui-ci est probablement trop court pour servir à quelque chose. Cependant, de temps en temps, un timeout plus long de 1s est utilisé. On voit que le pointeur vers la structure timespec associé est toujours le même, ce qui suggère qu’elle a été modifiée en place, mais malheureusement perf trace ne déréférence pas les pointeurs donc on ne peut pas en savoir plus sur les valeurs de timeout utilisées avec cet outil-là.
  • Il arrive occasionnellement à perf trace de se tromper sur la durée d’un appel système et sortir une valeur franchement aberrante. Je n’ai pas d’explication à ce phénomène à l’heure actuelle, je constate juste qu’il est heureusement assez rare pour peu gêner en pratique.

Cette approche d’analyse d’activité système (combiner un premier résumé des appels systèmes avec une étude plus détaillée de ceux qui semblent intéressants) fonctionne bien tant que le phénomène que nous cherchons à observer…

  1. Est reproductible ou de longue durée (pour avoir le temps de faire deux appels à perf trace)
  2. N’implique pas un des appels système utilisés par perf trace.

Exercice : L’option --summary/-s peut aussi être intéressante pour faire une analyse “haut niveau” de l’activité des commandes/processus individuels.

Reprenez la commande tree /usr >/dev/null étudiée plus haut, et utilisez cette option pour faire un résumé de ses appels système. Remarquez la plus grande clarté du résultat, mais aussi l’impact moins grand de perf stat sur les performances de la commande dans cette configuration.

Vous pouvez aussi tester et comparer l’équivalent strace de cette option, strace -c. Si vous appliquez les deux utilitaires au processus bash (avec l’option -f pour strace), vous constaterez que leur comportement diffère un peu : perf trace fournit des statistiques pour chaque processus, alors que strace ne fournit qu’un total accumulé sur tous les processus enfants.

Approche par spécialisation des CPUs

Cette seconde approche nécessite un peu plus de préparation, mais elle vous offrira en contrepartie une sortie plus “épurée” et focalisée sur l’information que vous recherchez, ainsi qu’un plus faible impact de perf trace sur les performances des programmes étudiés.

L’idée est d’utiliser un outil comme taskset pour isoler les processus que vous voulez étudier sur certains coeurs CPU dans un premier temps, puis pour lancer perf trace sur d’autres coeurs CPU dans un second temps, en lui indiquant de ne surveiller que les coeurs CPU où les tâches isolées se trouvent. Cela peut être fait avec l’option --cpu/-C, comme dans perf stat.

Sur srv-calcul-ambulant, une partie du travail a déjà été fait pour vous, puisque les tâches interactives et services systèmes sont paramétrés pour s’exécuter sur les coeurs CPU 0,1,18 et 19 tandis que les jobs Slurm s’exécutent sur les coeurs CPU restants (2-17 et 20-35).

Vous pouvez donc, sans préparation supplémentaire, surveiller l’activité système des coeurs de benchmark quand vous lancez un job Slurm, comme ceci :

# Dans un shell
perf trace -C 2-17,20-35

# Dans un autre shell
srun true

Il y a cependant deux limites importantes à cette approche :

  • Vous voyez également l’activité système associée aux jobs Slurm de tous les autres utilisateurs. Si vous êtes malchanceux, quelqu’un d’autre va faire un srun au même moment et compliquer grandement l’interprétation de votre mesure.
  • Vous prenez du temps CPU sur une session interactive pour faire fonctionner perf trace, alors que les ressources CPU associées aux sessions interactives sont très limitées.

Je vous recommande donc de la réserver aux situations où l’approche précédente est inapplicable.

Approche par filtrage de l’activité associée à perf

Une dernière manière d’éviter de suivre les appels systèmes associés à l’exécution de perf trace avec perf trace est de demander à celui-ci de les ignorer avec l’option --filter-pids, qui prend en paramètre une liste de PIDs (identifiants de processus) séparés par des virgules et fait en sorte que ni l’activité de ces processus, ni celle de perf ne sera présente dans la sortie de perf trace.

Pour alléchante que soit cette dernière approche, elle a plusieurs problèmes :

  • Dès que la sortie de perf trace suit un trajet compliqué à travers le système (comme c’est le cas lorsqu’on la redirige ou quand on lance perf trace via srun), le nombre de processus à exclure augmente, et la représentativité des mesures diminue d’autant.
  • Les PIDs des processus à exclure ne sont pas forcément connus d’avance, et les déterminer peut nécessiter des acrobaties shell complexes.

Tracepoints

A la fin de la partie sur perf list, nous avons évoqué comment perf stat nous permettait d’aller au-delà des appels système et de compter divers événements internes au noyau Linux, via des tracepoints (points de code instrumentés) que nous pouvons énumérer avec perf list tracepoint.

Avec perf trace, nous avons accès à des informations supplémentaires sur ces tracepoints :

  • Nous pouvons observer le déroulé temporel des passages à ces endroits du noyau
  • Nous pouvons y connaître certaines informations choisies par l’auteur du tracepoint

Considérons, par exemple, la commande dd suivante, qui écrit quelques kilo-octets de données aléatoires dans un fichier du disque dur SATA du serveur d’une façon fabuleusement inefficace :

dd if=/dev/urandom of=/hdd/${USER}/randomness.bin bs=42 count=123 oflag=sync

En examinant les appels système qu’elle fait, on n’apprendrait pas grand-chose :

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

Sommaire perf trace de l’exécution de dd

En revanche, on peut en apprendre davantage en étudiant l’activité des tracepoints associés à différentes parties du noyau Linux impliquées dans l’écriture de données sur le disque :

  • Le système de fichier utilisé sur /hdd (ext4, comme vous le dira mount)
  • Le périphérique bloc sous-jacent (/dev/sda1 pour les intimes)
  • Le cache disque qui s’intercale grosso modo entre les deux

Utiliser perf stat avec ces tracepoints nous permet déjà de formuler quelques hypothèses :

srun --pty \
    perf stat -e 'block:*,ext4:*,writeback:*' \
    dd if=/dev/urandom of=/hdd/${USER}/randomness.bin bs=42 count=123 oflag=sync
Sortie de perf stat

On constate ainsi que certaines parties du noyau semblent traversées une fois par bloc, d’autres plusieurs fois par bloc (le nombre de fois où elles sont traversées est approximativement multiple du nombre de blocs), et d’autres plus rarement, ce qui suggère qu’elle sont appelées une fois tous les quelques blocs ou bien durant les phases d’initialisation et de finalisation du programme.

Et, bien sûr, on voit aussi que certaines parties du noyau ne sont pas traversées, ce qui peut dans certain cas être une information tout aussi importante.

Mais le niveau de détail accessible via perf trace n’est tout simplement pas comparable :

srun --pty \
    perf trace -e 'block:*,ext4:*,writeback:*' \
    dd if=/dev/urandom of=/hdd/${USER}/randomness.bin bs=42 count=123 oflag=sync
Sortie de perf trace

Avec ce nouvel outil, on commence vraiment à voir assez précisément qu’est-ce que fait le noyau Linux des requêtes d’écriture disque qu’on lui envoie, quelles parties du noyau sont sollicitées, quels ordres sont envoyés au matériel sous-jacent… et ce niveau de détail peut faire la différence quand on essaie de démêler pourquoi une application relativement complexe est loin d’atteindre les performances d’E/S théoriquement permises par le périphérique de stockage qu’elle utilise.

Exercice : Répétez cette analyse en remplaçant oflag=sync par conv=fsync, puis en supprimant carrément cette option. Qu’est-ce que ça change au niveau de l’activité noyau ?

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.

Autres options

Similarités et différences avec perf stat

Comme d’autres commandes perf que nous avons vu précédemment, perf trace supporte les options --delay/-D, --cpu/-C, --event/-e ainsi que la surveillance d’un processus, thread ou utilisateur système spécifique, avec des sémantiques similaires. Comme nous avons déjà abordé ces options dans la partie sur perf stat, nous ne reviendrons pas dessus ici.

Contrairement aux commandes perf que nous avons vu précédemment, l’option --event/-e de perf trace supporte des motifs négatifs tels que -e '!futex,!clock_nanosleep'. Ceux-ci permettent de suivre tous les appels système à l’exception de ceux qu’on a listés.

Options de filtrage

De cette partie du TP, vous avez probablement compris le principal défi auquel on fait face quand on utilise perf trace : survivre à l’énorme flux d’information qui en sort.

Sans surprise, cette commande possède donc une belle palette d’options pour sélectionner des événements particulièrement intéressants et n’afficher qu’eux. Quelques exemples :

  • --duration N.M où N.M est un nombre de millisecondes, permet de n’afficher que les appels systèmes qui durent suffisamment longtemps. Cette option n’est pas utilisable quand on étudie des tracepoints, puisque ceux-ci ne sont que des points temporels sans notion de début et de fin.
  • --failure permet de n’afficher que les appels système qui ont échoué.
  • --max-events=N permet d’arrêter l’enregistrement après N événements.
  • --switch-on <event> et --switch-off <event> permettent d’attendre qu’un certain événement soit survenu avant de démarrer/arrêter l’enregistrement.
  • --filter permet de n’afficher que des événements dont les paramètres vérifient certains prédicats logiques.

Clarifions un peu cette dernière option en prenant l’exemple d’un job perf trace qui fait un suivi des interruptions matérielles :

srun --pty \
    perf trace -a -e irq:irq_handler_entry \
    sleep 0.01
Sortie de perf trace

Comme vous pouvez le constater, même sur un enregistrement de 10 millisecondes effectué sur un système au repos, la sortie est monopolisée par la gestion du trafic réseau (IRQ 72), interruption très fréquente. Nous pouvons choisir d’ignorer cette interruption matérielle avec le filtre --filter 'irq != 72', et dans ce cas même un enregistrement de quelques secondes devient lisible.

srun --pty \
    perf trace -a -e irq:irq_handler_entry --filter 'irq != 72' \
    sleep 10
Sortie de perf trace

Vous trouverez plus de détail sur la syntaxe utilisée par --filter dans la section “Event filtering” de la documentation des trace events du noyau.

Suivis des défauts de page

Une option particulièrement intéressante de perf trace est l’option --pf/-F, qui permet d’analyser les défauts de page. Sous Linux, cette interruption matérielle qui survient lorsqu’on tente d’accéder à une adresse mémoire qui n’est pas associée à de la RAM physique est utilisée pour toutes sortes de choses (swapping, allocation paresseuse de mémoire avec gestion du NUMA par first touch), et ces astuces d’implémentation peuvent parfois causer des problèmes de performance difficiles à comprendre. Il est donc intéressant d’avoir un outil pour en faire le suivi.

Enregistrement des données

Il est aussi possible d’enregistrer les informations produites par perf trace avec la commande perf trace record puis de les analyser en différé avec perf trace -i <fichier perf.data>. Cela génère du trafic vers le support de stockage sous-jacent, donc les précautions discutées précédemment demeurent valables.

perf report

Les outils que nous avons vus jusqu’à présent fournissent soit un comptage d’événements sur une certaine fenêtre de temps (perf stat), soit une information à grain fin (perf annotate, perf trace). Ils peuvent donc être difficiles à utiliser sur des programmes complexes, où l’on ne veut pas seulement connaître la nature de l’activité système mais aussi localiser son origine (code source de l’application, bibliothèque utilisée…).

Pour répondre à ce besoin, on peut utiliser perf report, qui s’utilise en association avec perf record (comme perf annotate) et détermine quelles fonctions d’une application et de ses dépendances génèrent l’essentiel de l’activité système à laquelle on s’intéresse. C’est le type d’analyse que l’on a le plus souvent en tête quand on parle de “profilage”.

Prérequis

Pour corréler des mesures à des emplacements dans le code source de l’application, perf report a besoin de comprendre la façon dont l’application s’exécute. En simplifiant un peu…

  • L’analyse sera relativement simple pour les programmes compilés avant l’exécution, le cas habituel en C/++, Fortran et Rust. Elle est basée sur les métadonnées des binaires ELF, communes à tous ces langages. Il faudra juste penser à compiler avec des informations de déboguage (flag -g de GCC et clang) pour des résultats optimaux.
  • Quand les programmes sont compilés à la volée, le cas habituel en Java, JavaScript et Julia, il faudra que perf puisse communiquer avec le compilateur pour obtenir les informatiques requises. Beaucoup de compilateurs populaires le permettent, mais souvent au prix d’une reconfiguration/recompilation avec des options spéciales qui ne sont pas activées dans les paquets binaires de la plupart des distributions Linux…
  • Et quand les programmes sont interprétés, le cas habituel en Python, Ruby et bash, on ne pourra souvent se rapporter qu’au code source de l’implémentation de l’interpréteur et des bibliothèques externes compilées statiquement, ce qui sera rarement satisfaisant.

Notez la répétition de l’expression “cas habituel” ci-dessus. Le modèle d’exécution n’est pas une propriété d’un langage, mais d’une implémentation de ce langage. On peut compiler du C++ à la volée comme le fait l’interpréteur cling de ROOT, tout comme on peut compiler du Python statiquement (Cython, Pythran) ou à la volée (Numba, PyPy). Mais la plupart des langages de programmation ont un type d’implémentation privilégié.

Par ailleurs, nous verrons plus loin que comme perf annotate et perf trace, perf report pourra effectuer une analyse plus fine si il dispose des informations de déboguage de l’application et de tous les binaires externes qu’elle utilise : noyau du système d’exploitation, bibliothèques…

Profil simple

Dans cette partie, nous allons nous intéresser à un programme qui implémente une simulation physique simple : un gaz d’électrons, protons et neutrons dans une boîte avec des interactions électrostatiques et nucléaires entre ces particules. Le programme est écrit dans un style qui rend hommage aux pratiques modernes de programmation en physique des particules, avec notamment un important recours aux techniques de programmation orientée objet.

Comme les programmes précédents, ce programme prend deux paramètres :

  • Un nombre de particules à simuler
  • Un nombre de pas de temps à simuler

Si vous ajustez ces paramètres, gardez en tête que la complexité du calcul est quadratique en fonction du nombre de particules puisque chaque particule interagit avec toutes les autres.

Un premier examen de l’exécution via perf stat suggère que le CPU ne rencontre pas de difficulté majeure pour exécuter ce programme…

srun --pty \
    perf stat -d \
    ./TPhysics.bin 500 100

Statistiques perf stat

…mais comme nous l’avons vu précédemment, ce n’est pas une condition suffisante pour conclure que le programme fait un usage optimal du temps CPU.

Voyons maintenant l’éclairage nouveau apporté sur cette question par perf report. Comme avec perf annotate, on doit d’abord enregistrer des données avec perf record, puis visualiser les mesures dans un deuxième temps avec perf report :

srun --pty \
    perf record \
    ./TPhysics.bin 500 100 \
&& perf report

Sortie par défaut de perf report

Nous nous retrouvons dans une interface console interactive ayant certaines similarités avec celle de perf annotate. Après un bref en-tête donnant des statistiques globales sur la mesure (nombre d’échantillons enregistrés et nombre approximatif de cycles CPU écoulés) et des titres de colonnes, perf report affiche une table qui indique, par ordre décroissant de fréquence, les fonctions qu’exécutait le plus fréquemment le programme lorsque des échantillons ont été enregistrés.

Pour chaque fonction, perf report indique…

  • Le pourcentage des échantillons où le programme se trouvait dans une fonction donnée, qui est un estimateur du pourcentage du temps CPU passé à exécuter chaque fonction.
    • Un code couleur est utilisé pour mettre en valeur les fonctions qui semblent contribuer grandement à la consommation de CPU.
  • La commande exécutée, information qui peut être utile quand on étudie des activités multi-processus telles qu’un pipeline bash.
  • Le binaire d’où est issu la fonction exécutée (programme ou bibliothèque).
  • Le nom de fonction/symbole ELF, avec un petit indicateur [.] qui indique que les fonctions qu’on regarde proviennent de code applicatif. Pour du code noyau, ce serait [k].

…et l’on constate donc que notre programme emploie l’essentiel de son temps CPU à exécuter différentes parties de l’allocateur mémoire de la bibliothèque standard C. Il n’est donc pas limité par le calcul flottant, comme on aurait pu le penser naïvement.

On observe aussi une première difficulté liée à l’utilisation de perf report : de par son mode de fonctionnement, cet outil marque des nuances entre des points d’implémentation qui n’ont pas d’importance pour nous. Par exemple, au premier niveau d’analyse que nous souhaitons avoir ici, la nuance entre _int_free, les différentes overloads de operator delete et operator delete@plt n’est pas pertinente, et on voudrait avoir juste une somme des coûts CPU associés à l’appel de de l’allocateur mémoire. Nous verrons plus loin comment l’obtenir.

Enfin, mentionnons que l’on peut sélectionner l’une des fonctions du programme avec les flèches haut/bas du clavier et afficher son assembleur annoté dans le style de perf annotate avec la touche a du clavier. Comme dans perf annotate, la touche h permet d’afficher l’aide des commandes clavier et la touche q permet de quitter perf report.

Exercice : Afficher l’assembleur annoté de TParticle::reidInteraction et découvrez de quelle nature sont ses instructions CPU les plus chaudes. Référez-vous à la partie sur perf annotate si vous avez oublié le mode de fonctionnement de l’assembleur annoté.

Critères de tri

Fondamentalement, perf report produit un histogramme : on groupe les échantillons d’activité système observée selon certains critères, pour l’activité CPU c’est par défaut la commande (comm), le binaire (dso) et la fonction (sym) où une activité a été observée. Puis on indique dans quels cas, selon ces critères, on observe le plus d’activité système.

Il est possible, si on le souhaite, de modifier ces critères de tri. Par exemple, on peut demander un histogramme à gros grain, qui ne trie que par commande et par binaire…

perf report --sort comm,dso

Rapport à gros grain

…et on en déduit qu’au plus 39% du temps CPU écoulé est attribuable au code directement écrit dans TPhysics.cpp et TPhysics.hpp, le reste étant passé dans du code de la bibliothèque standard. Autrement dit, si l’on ne modifiait que le code de la simulation sans affecter le nombre et le type d’appels à la bibliothèque standard, le programme irait au mieux 39% plus vite.

(“Au mieux” parce qu’une partie du code de la bibliothèque standard se retrouve inlinée dans TPhysics.bin et ne serait pas affectée par ces manipulations, et aussi parce qu’il faudrait pour atteindre ce gain maximal ramener le temps d’exécution du code métier à zéro.)

A l’inverse, on peut demander un histogramme à grain plus fin, par exemple en demandant pour quelles lignes de code source la plus forte activité système est observée :

perf report --sort comm,dso,sym,srcline

Rapport à grain fin

Ce ne sont que des exemples, bien d’autres critères sont disponibles, notamment le PID (utile pour discriminer le processus source dans des environnements multi-processus) et le TID (pour discriminer le thread source dans des programmes multi-thread). Consultez la partie sur l’option --sort de perf help report pour en savoir plus.

Exercice : Utiliser perf report pour afficher un histogramme où les échantillons sont groupés par binaire et par ligne de code source uniquement.

Vous observerez quelques erreurs de BFD (la bibliothèque issue de binutils utilisée par perf pour manipuler des binaires ELF), qui sont liées au fait que l’interprétation d’informations de déboguage n’est malheureusement pas une science exacte, surtout au niveau du code noyau.

Dans le cas présent, vous pouvez éviter ces erreurs en ne mesurant que l’activité applicative, sans regarder celle du noyau. Il suffit pour cela de relancer la commande perf record en y ajoutant l’option -e cycles:uP (relisez la partie sur perf list si vous avez oublié ce que ce :uP signifie).

Graphes d’appels

Motivation

Dans la partie sur perf trace, nous avons vu que la mesure de graphes d’appels nous permet de comprendre l’origine d’une activité système. Dans le cadre de perf report, cette mesure est aussi possible, et même très utile :

  • Elle nous permet de discriminer des appels à une fonction qui surviennent pour différentes raisons. Dans notre exemple, nous pouvons ainsi savoir quelles parties du code passent le plus de temps à appeler l’allocateur mémoire, afin de prioriser les optimisations associées.
  • Elle nous permet d’acquérir une vision plus haut niveau de l’activité système de notre programme. Dans notre exemple, nous n’avons plus besoin de nous préoccuper de détails d’implémentation de l’allocateur mémoire pour effectuer notre analyse.

Cependant, il y a aussi quelques nouvelles précautions à prendre par rapport à perf trace, qui sont principalement liées au fait que…

  • perf record est généralement utilisée pour échantillonner à haute fréquence (ex: par défaut, on échantillonne le pointeur d’instruction à plusieurs kHz), là où perf trace est principalement pensée pour suivre des appels système relativement peu fréquents.
  • perf record produit un fichier perf.data en sortie au lieu de traiter les données à la volée, ce qui peut créer un goulot d’étranglement en termes de bande passante de stockage ou bien perturber l’exécution d’un benchmark sensible à la performance de stockage.

Pour éviter ces problèmes, on voudra généralement spécifier manuellement une fréquence d’échantillonnage peu élevée et parfois écrire les données de sortie en RAM.

Exemple DWARF

Voici un premier exemple de cycle perf record/report qui prend ces deux précautions, en utilisant des graphes d’appels basées sur les informations de déboguage (qui constituent, pour rappel, le meilleur choix par défaut quand on est indécis) :

srun --pty \
    perf record -F 1000 --call-graph=dwarf -o /dev/shm/${USER}/perf.data \
    ./TPhysics.bin 500 100 \
&& mv /dev/shm/${USER}/perf.data . \
&& perf report

Rapport avec graphes d’appels

On peut constater la présence de nombreuses nouveautés dans l’affichage :

  • L’ancienne colonne Overhead est renommée en Self et placée en 2e position.
  • Il y a une nouvelle colonne Children, qui représente une estimation du temps passé dans une fonction X ainsi que dans l’ensemble des fonctions directement ou indirectement appelées par X. Cette colonne est la nouvelle clé de tri.
  • Au niveau des symboles, le code de discrimination entre fonctions utilisateur et noyau cette version de perf semble un peu bogué…
  • perf est parfois (hélas pas à tous les coups) en mesure d’estimer le temps CPU consommé par les fonctions inlinées, ce qui est utile car elles sont souvent critiques pour les performances.

Une lettre + a également fait son apparition sur la gauche de l’affichage. Ce signe nous indique qu’en mettant en surbrillance une fonction et en appuyant sur la touche Entrée, on peut afficher la répartition du temps CPU entre les fonctions appelées, et ce récursivement si on le souhaite :

Répartition du temps CPU dans main

Limites du DWARF

Voilà pour les bonnes nouvelles. Maintenant, la grosse mauvaise nouvelle. Il ne vous aura pas échappé que seulement 79% des piles d’appels ont correctement été reconstruites jusqu’à la fonction main. Ceci est une occasion de plus de répéter un point central dans l’interprétation de toutes les mesures perf basées sur l’analyse des informations de déboguage :

Les analyses basées sur les informations de déboguage sont loin d’être infaillibles, et ce pour toutes sortes de raisons difficiles à analyser pour le non-expert : bogues du compilateur, de binutils, de perf, ou taille d’échantillon de pile insuffisante.

En pratique, pour les 21% de piles d’appels restantes, la remontée de la pile d’appels a bien fonctionné jusqu’à un certain point, puis une erreur s’est produite en cours de route et perf a atterri à une adresse d’instruction insensée avant d’abandonner, comme on peut s’en convaincre avec une analyse à gros grain de ces échantillons pathologiques basée sur le binaire source :

perf report --call-graph=comm,dso

Vue en DSO du graphe d’appels

La conséquence de cela, c’est que les pourcentages Children obtenus sur un graphe d’appels DWARF ne sont pas fiables à 100%. Au mieux, les échantillons mal reconstruits sont équirépartis dans le programme et on a “juste” besoin de normaliser les valeurs obtenues dans le graphe d’appels qui commence à main par 1/79% = 1.26. Au pire on a un problème qui se produit toujours au même endroit du code, et cela créera un biais systématique d’au plus 21% dans les mesures.

Sans information complémentaire, une façon prudente d’interpréter les données est de conclure quel les pourcentages “Children” que l’on obtiendrait avec une mesure parfaite sont compris entre la valeur mesurée X et X+(100-M) où M est le pourcentage “Children” mesuré pour la fonction main (qui devrait être ~100% pour tout programme dont le temps d’exécution n’est pas négligeable, en l’absence d’erreur de reconstruction des piles d’appels).

Pour faire mieux, il faut avoir sous la main une deuxième technique de profilage qui n’est pas sujette aux mêmes biais que les graphes d’appels DWARF afin d’évaluer le niveau de biais lié aux piles d’appels tronquées. Cela peut être un profilage manuel au sein de l’application, mais dans le cadre de perf report, on peut aussi parfois utiliser les graphes d’appels LBR.

Exemple LBR

Au niveau de perf record, on utilise ce style de graphe d’appels avec l’option --call-graph=lbr :

srun --pty \
    perf record --call-graph=lbr \
    ./TPhysics.bin 500 100 \
&& perf report

Rapport avec graphes d’appels LBR

Sur ce programme qui reste relativement simple, et donc sous la limite des 32 appels de fonction imbriqués supportés par le LBR de notre CPU, le graphe d’appels par LBR produit de bons résultats. On peut observer les différences suivantes avec le graphe d’appels par information de déboguage :

  • Presque toutes les piles d’appels sont ici reconstruites jusqu’à main.
  • La discrimination entre code applicatif et code du noyau Linux fonctionne à tous les coups.
  • On n’a plus de visibilité sur les fonctions inlinées.
  • L’information LBR est beaucoup plus légère que les copies de piles requises pour l’analyse DWARF, donc on n’a plus besoin d’imposer une limite de fréquence d’échantillonnage ou d’émettre le fichier de sortie sur /dev/shm. L’analyse par perf report est aussi plus rapide.

Au niveau du résultat, on peut comparer le premier niveau de hiérarchie sous main pour les deux techniques, en descendant jusqu’à la première fonction non inlinée dans le cas du graphe d’appels DWARF pour faciliter la comparaison :

Comparaison DWARF/LBR

Une petite règle de trois permet de se convaincre qu’il n’y a pas de biais systématique majeur ici : en renormalisant les pourcentages “Children” du graphe d’appels DWARF par 1/0.7978 (l’inverse du pourcentage d’échantillons correctement reconstruit jusqu’à main), on trouve…

  • 40.6% pour TProton::interaction
  • 19.3% pour TNucleon::interaction
  • 14.1% pour TChargedParticle::interaction
  • 8.5% pour TVector::vectorOp
  • 1.8% pour std::vector<double>::operator=

…ce qui est en plutôt bon accord avec le profil mesuré par la méthode LBR. On notera cependant quelques différences. Par exemple l’approche DWARF attribue le coût de déallocation des std::vector à la fonction _int_free de la glibc alors que l’approche LBR l’attribue à l’opérateur delete de la libstdc++, et il semblerait que l’approche DWARF ne parvienne pas à reconstruire les piles d’appels passant par la méthode virtuelle TChargedParticle::interaction.

Exercice : Répétez cette analyse avec les enfants de la fonction TParticle::reidInteraction.

Notez la présence d’une indication _start dans la visualisation de perf report, qui représente la pile d’appels au-dessus de la fonction courante (de _start jusqu’à la fonction active).

Contourner la limite de taille du LBR

Sur des programmes plus complexes, l’approche LBR n’est souvent pas directement applicable, car les piles d’appels sont profondes et dépassent largement la limite de 32 appels de fonction du matériel. C’est particulièrement vrai quand on profile un framework scripté avec du Python, car l’implémentation de l’interpréteur CPython utilise beaucoup d’appels récursifs.

Dans ce genre de cas, on peut demander à perf report de tenter de reconstruire les piles d’appels tronquées via une technique astucieuse, le LBR-stitching. L’idée est la suivante : considérons un CPU avec une capacité LBR de 4 appels et supposons que l’on ait un programme effectuant la série d’appels imbriqués suivante :

  • A1 appelle A2…
    • …qui appelle A3
      • …qui appelle A4
        • …qui appelle A5
          • …qui appelle A6

En supposant qu’un temps CPU significatif soit écoulé dans toutes les fonctions, perf observera dans ses échantillons les piles d’appels suivantes:

  • A1
  • A1 → A2
  • A1 → A2 → A3
  • A1 → A2 → A3 → A4
  • A2 → A3 → A4 → A5 (tronqué par le LBR)
  • A3 → A4 → A5 → A6 (tronqué par le LBR)

En corrélant les piles d’appels observées dans différents échantillons, perf pourra alors reconstruire les piles d’appels tronquées en déduisant que A2 a probablement été appelée par A1 et A3 a probablement été appelée par A2.

L’opération est moins hasardeuse que cette description simplifiée ne pourrait le laisser paraître, car perf n’a pas seulement accès au nom de la fonction appelante, mais plus précisément à l’adresse de l’instruction CALL associée. Mais cela reste un post-traitement dont la fiabilité n’est pas parfaite :

  • En cas d’appel à des méthodes virtuelles dans du code orienté objet, une même instruction CALL peut appeler plusieurs fonctions différentes.
  • Si beaucoup de fonctions ne font rien d’autre qu’appeler d’autres fonctions (cas de codes appliquant les bonnes pratiques d’ingénierie logicielle de façon un peu trop orthodoxe), perf aura peu d’échantillons à ces endroits, ce qui limitera l’applicabilité du stitching.
  • Plus la fréquence d’échantillonnage est faible, moins cette approche fonctionne.
  • Ce post-traitement est plus vulnérable que l’approche LBR pure au code “pathologique” (ex: lancement et capture d’exceptions).

On peut activer le stitching en passant l’option --stitch-lbr à perf report. Et quand cela ne fonctionne pas, une alternative courante pour valider les résultats des graphes d’appels DWARF est de profiler manuellement son code, par exemple en minutant et affichant le temps écoulé pendant l’exécution d’étapes de traitement de haut niveau (idéalement au moins 100ms).

Options d’affichage du graphe d’appels

perf report dispose de plusieurs options pour configurer l’affichage des graphes d’appels. On peut notamment mentionner…

  • L’option --call-graph qui se compose de plusieurs sous-options séparées par des virgules (--call-graph=x,y,z,...). Les quatre premières sont les plus importantes :
    1. La sous-option print_type définit le comportement global quand on appuie sur Entrée après avoir mis une fonction en surbrillance. Les choix les plus utiles sont :
      • fractal : Les pourcentages de temps CPU ne sont plus absolus, mais relatifs à la fonction choisie. Soyez prudent avec ce mode, il est facile de s’y laisser piéger par un gros pourcentage relatif qui correspond en fait à un faible pourcentage absolu (qui peut ne même pas être statistiquement significatif).
      • flat : La vue n’est plus hiérarchique et on a directement une liste de toutes les piles d’appels associées à la fonction sélectionnée. Ce mode est utile pour des programmes avec des piles d’appels très profondes.
      • graph : Mode par défaut (vue hiérarchique avec des pourcentages absolus), utile quand on veut configurer les autres sous-options.
    2. La sous-option threshold, définit en dessous de quel pourcentage de temps CPU une fonction sera supprimée de l’affichage (0.5% par défaut). Cela permet d’obtenir un affichage plus ou moins détaillé.
    3. La sous-option print_limit n’est considérée qu’en mode --stdio et limite le nombre de piles d’appels enfant affichées par parent (0 = illimité est le choix par défaut)
    4. La sous-option order permet de basculer entre un affichage basé sur les enfants et un affichage basé sur les parents :
      • Le mode par défaut est caller, où l’on affiche les fonctions appelées par la fonction sélectionnée (fonctions enfants, caller-based call graph).
      • En mode callee, perf affiche quelles fonctions font le plus souvent appel à la fonction sélectionnée (fonctions parents, callee-based call graph). Ce mode est utile quand on veut savoir qui fait le plus souvent appel à une fonction utilitaire. Il est aussi plus proche du fonctionnement interne de perf report (partir d’un échantillon CPU et remonter la pile d’appels), donc utile pour déboguer.
  • L’option --percent-limit a un effet analogue à la sous-option threshold de --call-graph, mais s’applique aux entrées d’histogramme (la liste affichée lors de l’ouverture de perf report), alors que threshold s’applique aux entrées parent/enfant affichées quand on parcourt la hiérarchie des appels avec la touche Entrée.
  • L’option --max-stack permet de contrôler la limite de taille de pile d’appels après laquelle perf report abandonnera l’idée de remonter jusqu’au point d’entrée (127 par défaut).

Exercice : Affichez quelles parties du code font le plus appel à _int_malloc, free et __dynamic_cast, et dans quelles proportions.

Liste des binaires utilisées

Quand on utilise des graphes d’appels basés sur les informations de déboguage DWARF (ce qu’on est souvent amené à faire sur les gros programmes), on est fréquemment confronté au besoin d’installer des paquets de déboguage, et donc de savoir quels paquets il faut installer.

Dans cette optique, la commande perf buildid-list peut être utilisée pour savoir quels binaires ont été utilisés lors de l’exécution d’un programme et quels sont les build-id associés :

perf buildid-list

Sortie de perf buildid-list

Le build-id est une somme de contrôle d’un binaire qui permet de différencier différents binaires qui portent le même nom mais ne correspondent pas exactement à la même version du code source ou n’ont pas été compilés avec les mêmes options. Le gestionnaire de paquets de certaines distributions Linux, dont openSUSE, peut installer des symboles de déboguage sur la base d’un build-id sans avoir besoin de chercher le nom et la version du paquet associé.

Tracepoints

Comme on l’a vu dans la partie sur perf annotate, perf record évalue par défaut quels régions du code consomment la plus grande quantité de cycles CPU, mais il est possible de suivre d’autres événements avec la désormais habituelle option --event/-e.

Par exemple, avec perf record -e L1-dcache-load-misses, on peut déterminer dans quelles régions du code il y a le plus de défauts de cache L1. Cela peut offrir un éclairage alternatif à la seule étude de consommation de cycles CPU, qui est utile quand on optimise l’utilisation de ressources CPU comme les caches qui sont partagées entre différentes régions du code. En effet, quand ces ressources sont sous tension, on peut observer des comportements contre-intuitifs, par exemple ajouter un nouveau module de code peut ralentir un module préexistant qui n’a pas été modifié.

Mais il y a un autre type d’événements qui est particulièrement intéressant à analyser avec perf record et perf report, c’est les événements issus du noyau Linux, comme les tracepoints.

En effet, nous avons vu que leur suivi “en direct” avec perf trace peut être parfois difficile :

  • Il est facile de se retrouver submergé dans par un journal gigantesque d’événements, bien que ce journal soit souvent très redondant.
  • La stratégie de perf trace d’afficher du texte à chaque fois que l’événement survient ne passe pas à l’échelle quand l’événement étudié est fréquent.
  • Si l’action d’afficher du texte déclenche l’événement étudié, perf trace peut se retrouver dans une situation indésirable de récursion infinie.

Nous allons voir maintenant que l’étude de ces mêmes événements avec perf record et perf report fournit un éclairage différent qui peut aider à surmonter ces difficultés.

Appels système d’une commande

Considérons la commande suivante, qui lit des zéros depuis /dev/zero est les rejette dans /dev/null. C’est un benchmark simple de la performance des pseudofichiers du noyau Linux.

srun --pty \
    bash -c 'tail -c10G /dev/zero >/dev/null'

On l’a vu, perf trace peut nous donner un premier aperçu des appels systèmes effectués par cette commande…

srun --pty \
    perf trace -s \
    bash -c 'tail -c10G /dev/zero >/dev/null'

Sommaire des appels système via perf trace

…et on constate sans surprise que l’essentiel de l’activité système générée et du temps d’exécution associé sont liés aux lectures et écritures du système de fichier effectuées par la commande tail.

Mais il y a aussi des choses plus surprenantes, notamment le fait que le nombre de lectures d’écritures n’est pas le même. Et on pourrait aussi se poser des questions d’implémentation, par exemple vouloir savoir si la taille des lectures et écritures en octets est toujours la même. Le mode “résumé” de perf trace que nous venons d’utiliser ne permet pas de répondre à ces questions.

A raison de millions d’appels système read et write effectués, il ne serait pas raisonnable d’afficher la liste complète de ces appels système avec perf trace et la relire manuellement. Il serait aussi laborieux d’extraire la sortie de perf trace dans un fichier et l’analyser avec un script, car elle serait très volumineuse. C’est donc un cas où perf trace n’est pas l’outil adapté.

Voyons maintenant ce que perf record et perf report peuvent nous dire sur les appels système read et write effectués par cette commande :

srun --pty \
    perf record -e '{syscalls:sys_enter_read,\
                     syscalls:sys_exit_read,\
                     syscalls:sys_enter_write,\
                     syscalls:sys_exit_write}' \
                -o /dev/shm/${USER}/perf.data \
    bash -c 'tail -c10G /dev/zero >/dev/null' \
&& mv /dev/shm/${USER}/perf.data . \
&& perf report

Histogramme des appels système via perf report

Nous introduisons ici une nouvelle fonctionnalité de perf record, les group events, reconaissable à sa syntaxe {} qui doit être mise dans une chaîne de caractère pour ne pas être mal interprétée par bash. Elle permet de visualiser simultanément l’activité de plusieurs événements (ici l’entrée et la sortie des appels à read et write) sous colonnes “Overhead” séparées dans perf report.

Par ailleurs, notez que le volume de données produit est si important que nous avons besoin d’utiliser /dev/shm pour stocker la sortie. Nous reviendrons sur cette question.

Nous voyons que contrairement à perf trace, perf report ne nous donne pas une liste ordonnée des appels système effectués, mais un histogramme de ceux-ci, ce qui est bien plus exploitable pour cet exemple. Il en ressort que…

  • Les lectures du fichier source s’effectuent toujours au sein du même tampon, et tail demande toujours 8192 octets au noyau.
  • Le noyau fournit toujours l’intégralité des 8192 octets demandés par read.
  • Les écritures de fichier, elles, s’effectuent avec une granularité de 4096 octets et depuis deux tampons différents (l’un des deux étant le tampon utilisé pour read avec un décalage de +4096 octets). C’est a priori étonnant et à examiner de plus près.
  • Le noyau écrit toujours l’intégralité des 4096 octets fournis à write.

En revanche, par rapport à perf trace, nous avons perdu une information importante, celle de l’ordre dans lequel se produisent les événements. C’est un compromis inhérent à la représentation en histogramme de perf report.

Nous pouvons obtenir une idée de cet ordre en étudiant un bref extrait de la très longue sortie de perf trace, après avoir indiqué à ce dernier d’attendre 100ms après le démarrage du programme pour ne pas voir la phase d’initialisation :

srun --pty \
    perf trace --delay 100 \
               -e read,write \
    bash -c 'tail -c10G /dev/zero >/dev/null' 2>&1 \
| head -n50

Extrait de l’historique des appels système via perf trace

Et nous pourrions ensuite vouloir savoir si des chemins de code différents sont à l’origine de ces deux types d’appels différents à l’appel système write, mais avant cela il va nous falloir aborder un prérequis à ce type d’analyse…

Améliorer l’efficacité

Si vous avez fait attention aux sorties des commandes perf record précédentes, vous avez peut-être tiqué sur les volumes de données produits. Il y a deux raisons à cela :

  • L’événement que nous suivons est très fréquent (plusieurs millions en quelques secondes).
  • Par défaut, perf record essaye d’enregistrer chaque occurence des événements tracepoint.

Ce dernier comportement est cohérent avec celui de perf trace, mais dans le cas où nous sommes il est inadapté, car il nous fait enregistrer des millions d’événements qui sont en réalité presque tous identiques. La situation s’aggraverait encore si on essayait d’enregistrer des graphes d’appels via la méthode DWARF, puisque cela ajoute un volume de données important par échantillon.

Nous avons déjà abordé ce problème précédemment, et évoqué une solution simple : enregistrer un petit échantillon des événements étudiés, qui soit aussi statistiquement représentatif que possible. Il suffit pour cela de spécifier une fréquence d’échantillonnage avec l’option --freq/-F :

srun --pty \
    perf record -F 10000 -e syscalls:sys_enter_write \
    bash -c 'tail -c10G /dev/zero >/dev/null' \
&& perf report

Rapport perf avec échantillonnage

Vous constaterez que du fait d’un léger biais statistique, la répartition des échantillons n’est pas de 50%/50% entre les deux appels à write. Mais le niveau de biais observé reste suffisamment faible pour que la mesure soit exploitable, et ce avec un fichier de données qui ne fait plus que 1 Mo.

En conclusion, les fréquences d’échantillonnage par défaut de perf record ne sont pas toujours adéquates (particulièrement sur les tracepoints), et il est donc recommandé de toujours évaluer rapidement la fréquence de l’événement étudié avec perf stat ou perf trace -s sur l’application cible et en déduire une valeur judicieuse pour le paramètre -F de perf report. Au bout du compte, la valeur optimale est celle…

  • Qui produit une statistique suffisante.
  • Qui génère un volume de données raisonnable.
  • Qui perturbe aussi peu que possible l’exécution du code étudié.

Exercice : Utilisez les différentes fonctionnalités de perf report abordées précédemment (notamment --call-graph et --sort) pour répondre à la question soulevée ci-dessus : de quel code provient l’utilisation de deux tampons différents pour les écritures ?

Notez que les graphes d’appels LBR ne sont pas supportés quand on enregistre des événements tracepoint. Je ne comprends pas pourquoi (le message d’erreur n’est pas cohérent avec ce que je comprends du mode de fonctionnement du LBR), mais je peux vous dire que c’est toujours le cas dans les versions récentes de perf (6.0.7), donc c’est une limitation avec laquelle il va sans doute falloir composer dans votre pratique quotidienne de l’outil.

Rappelez-vous aussi que si vos graphes d’appels DWARF sont tronqués, vous pouvez parfois régler le problème en augmentant la taille des échantillons de pile enregistrés par perf

Autres options

perf record et perf report sont deux commandes qui jouent un rôle central quand on utilise perf pour analyser des grosses applications. Elles ont donc, sans surprise, beaucoup d’options.

Options de perf record

Dans le cas de perf record, nous avons déjà abordé de nombreuses options utiles, mais quelques autres options méritent encore d’être mentionnées :

  • Il existe un certain nombre d’options de performance qui permettent de diminuer le nombre d’échantillons perdus quand on est forcé de travailler à fréquence d’échantillonnage élevée, au prix d’une consommation de ressources supplémentaire par perf : ordonnancement temps réel (--realtime/-r), taille des tampons de transit pour les données mesurées (--mmap-pages/-m), E/S asynchrone multi-thread (--aio)…
  • On peut demander d’enregistrer divers détails pour chaque échantillon mesuré : addresses mémoire virtuelles et physiques, timestamps, numéro du CPU où l’échantillon a été mesuré, direction prise au niveau des instructions de branchement, contenu des registres CPU…

Filtrage non destructif

Au niveau de perf report, il est possible de filtrer les données affichées (par PID, TID, UID, cgroup, binaire/DSO, CPU…). La différence par rapport aux options de filtrage de perf record, c’est qu’ici les données ont bien été enregistrées dans le fichier perf.data, mais on choisit juste de ne pas les afficher pour cette session perf report.

Sélection temporelle

perf report fournit également quelques options de filtrage et d’aggrégation temporelle, qui sont très utiles quand on étudie une application qui alterne entre plusieurs comportements dans le temps (typiquement des phases calcul vs ES ou initialisation vs traitement vs finalisation) :

  • Avec --time, on peut n’afficher que les données qui concernent une certaine fenêtre de temps dans les données issues du fichier perf.data. La fenêtre peut être donnée en pourcentage (ex : --time 15%-90%) ou en secondes (ex : --time 0.5,13.3). Et il est possible de fusionner des données issues de plusieurs fenêtres de temps.
  • Avec --sort time, qui se combine avec les autre options --sort abordées précédemment, on peut comparer l’activité de l’application au sein de “tranches de temps” de taille fixe contrôlées par l’option --time-quantum. Cela est notamment utile pour repérer les changements qualitatifs de comportement de l’application et en déduire les bonnes valeurs à passer à --time pour étudier ces comportements différents un par un.

Sortie non interactive

Une dernière option de visualisation utile de perf report est --stdio, qui produit une version non interactive de la table qui peut facilement être redirigée vers un fichier texte pour partage avec autrui ou post-traitement par un script. Mais pour cette dernière tâche, il y a un outil perf plus adapté que nous aborderons ultérieurement.

perf top

Premier contact

Dans la partie sur perf report, nous avons vu que perf permet d’étudier comment l’utilisation d’une ressources système, par exemple sa consommation de temps CPU, se répartit entre les fonctions d’un programme et des bibliothèques qu’il utilise.

perf top étend ce suivi à l’ensemble des programmes en cours d’exécution sur le système, fournissant en cela une alternative plus détaillée aux moniteurs systèmes traditionnels :

srun --pty perf top

Statistiques perf top

Sur cette capture d’écran, vous voyez la répartition du temps CPU entre les différentes fonctions des applications en cours d’exécution (indicateur [.]) ou du noyau Linux (indicateur [k]). Au moment où cette capture d’écran a été prise, il n’y avait pas d’autre tâche que perf top en cours d’exécution, par conséquent nous voyons surtout la consommation CPU de perf et l’activité noyau associée.

Même si l’analogie avec les outils de monitoring système aide à se faire une première idée, il y a quand même une nuance importante à retenir : contrairement aux outils classiques, perf top affiche par défaut un rapport sur l’ensemble des événements survenus depuis son lancement. Donc si par exemple une tâche qui ne consommait rien se met brusquement à consommer du CPU alors que perf top surveille le CPU depuis longtemps, la sortie de perf top ne va changer que progressivement, puisqu’au début cette nouvelle activité ne représentera qu’une part négligeable du temps CPU utilisé depuis le lancement de perf top. En retour, l’intérêt de cette approche est que sur un système “stationnaire”, la précision des statistiques s’améliorera au fil du temps.

Pour obtenir un comportement plus analogue à celui d’un moniteur système classique, où l’on n’affiche que l’activité système survenue depuis le dernier rafraîchissement de l’affichage, il faut utiliser l’option --zero, qui s’abbrévie en -z et que l’on peut aussi activer avec le raccourci clavier Z quand perf top est déjà en cours d’exécution.

Dans une telle utilisation, on aura souvent intérêt à ajuster aussi le paramètre --delay, abbrévié en -d, qui contrôle la fréquence de rafraîchissement de l’affichage.

Précautions d’emploi

En s’exécutant, perf top va faire l’équivalent d’un perf record -a et d’un perf report à intervalles de temps régulier. Par conséquent…

  • Pour des résultats optimaux, perf top doit disposer des symboles déboguages de l’ensemble des programmes et bibliothèques en cours d’utilisation. Selon la distribution Linux que vous utilisez, cela peut nécessiter une préparation système assez conséquente et laborieuse. Sur srv-calcul-ambulant, cette préparation a été effectuée pour l’ensemble des applications et bibliothèques du système disposant de symboles de déboguage.
  • La durée de traitement du rapport “perf report” doit être inférieure au temps entre deux rafraîchissements, sinon des cycles de rafraîchissement seront ratés. Et comme ce traitement s’exécute en parallèle des applications étudiées, il peut affecter leurs performances d’une façon qui biaisera la mesure. Il faut donc se restreindre à des mesures relativement économes en ressources CPU, et en particulier être très prudent avec l’option --call-graph=dwarf.

En dehors de ces points de vigilance, l’utilisation de perf top est très proche de celle de perf record et perf report. La quasi-totalité des options de perf top sont communes avec l’une ou l’autre de ces commandes, et je vous invite donc à vous référer aux sections associées pour plus d’informations.

perf probe

Au cours des sections précédentes, nous avons vu que perf n’est pas seulement un outil d’analyse de l’utilisation du temps CPU, mais peut surveiller un très grand nombre d’événements système, ce qui permet un suivi de l’activité du CPU et du noyau Linux à grain fin.

Mais l’ensemble des indicateurs d’activité système que nous avons suivis jusqu’à présent étaient basés sur des tracepoints, une forme d’instrumentation mise en place manuellement par les développeurs du noyau Linux en des points stratégiques du code du noyau. Avec cette instrumentation manuelle, il ne nous est par définition pas possible d’instrumenter du code qui n’a pas été pensé pour être instrumenté, que ce soit dans le noyau ou en-dehors, à moins d’être prêt à modifier et recompiler le code en question.

Dans cette section, nous allons voir que les tracepoints ne sont pas la seule forme d’instrumentation supportée par perf, et qu’il est en réalité possible d’instrumenter presque n’importe quel code pour qu’il soit observable par perf, que ce code soit situé dans le noyau Linux ou dans une application ou bibliothèque…

Infrastructure

L’instrumentation de code est une technique puissante pour répondre à toutes sortes de questions de performances. Dès lors qu’on chercher à connaître un paramètre d’un programme qui n’est a priori défini qu’à l’exécution (par exemple la taille typique de messages reçus via le réseau), la manière la plus simple de répondre à la question est d’instrumenter le code concerné pour en extraire cette information, fût-ce par un simple printf() bien placé suivi d’une recompilation.

Mais recompiler du code peut être laborieux, et faire des statistiques sur de l’information issue d’un printf dont le format est défini par l’utilisateur peut l’être tout autant. C’est pourquoi Linux fournit un grand nombre d’outils pour extraire de façon standardisée de l’information d’un code, avec ou sans recompilation, et que le code ait été conçu pour ça à la base ou non.

Types d’instrumentation

Les principales formes d’instrumentation Linux accessible par le biais de perf sont:

  • Les tracepoint, dont nous avons déjà beaucoup parlé. Il s’agit de points du noyau Linux qui ont été manuellement instrumentés par les développeurs de celui-ci, afin de pouvoir suivre quand du code y fait appel ainsi que la valeur de certains paramètres d’exécution (ex: taille d’une écriture de fichier pour le tracepoint syscalls:sys_exit_write).
  • Les kprobes sont un mécanisme permettant d’injecter du code en un point arbitraire du source du noyau Linux, pour détecter quand on passe en ce point du code noyau et éventuellement en extraire des informations (contenu de variables, paramètres et résultats de fonctions…). Cela se fait “à chaud”, il n’est pas nécessaire de recompiler son noyau ou redémarrer.
  • Les uprobes ont une flexibilité analogue aux kprobes, mais permettent d’instrumenter du code extérieur au noyau (exécutables, bibliothèques partagées…). Leur fonctionnement est analogue à celui d’un point d’arrêt dans un débogueur : le code est interrompu et passe la main au noyau, qui récupère l’information voulue avant de lui rendre la main.
  • Les USDT probes, pour User Statically-Defined Tracing, sont un mécanisme basé sur les uprobes qui permet à une application ou une bibliothèque de définir manuellement des points d’instrumentation, à la manière des tracepoints dans le noyau.

Outre le fait que les deux premières ciblent le code noyau et les suivantes ciblent le code applicatif, ces formes d’instrumentation se distinguent par leur performance et leurs garanties de stabilité.

Stabilité

Les tracepoint et USDT probes sont définis par le développeur du code concerné, et sont normalement soumis aux mêmes garanties de stabilité que toute autre API. Par conséquent, on peut s’attendre à ce que l’instrumentation associée reste disponible pendant un certain temps, ce qui permet par exemple de créer des tutoriels ou utilitaires d’analyse de performance basés sur l’information fournie par l’instrumentation.

Les kprobes et uprobes, en revanche, sont définis par la personne qui souhaite instrumenter du code, sans accord préalable avec les développeurs. On n’est pas limité aux interfaces publiques, et on peut instrumenter aussi profond dans l’implémentation qu’on le souhaite. Il n’y a donc aucune garantie de stabilité, et même un petit correctif d’une application peut casser une instrumentation basée sur ces mécanismes. Ils ne sont donc adaptés que pour des analyses ponctuelles.

Performances

L’instrumentation rajoute du code CPU à exécuter à chaque fois que l’exécution passe en un certain point du programme instrumenté, ainsi que du trafic mémoire. Elle a donc un impact sur les performances du code que l’on étudie.

Selon “System Performance: Enterprise and the Cloud” de Brendan Gregg (2e édition, 2020)…

  • Les tracepoints définis par le noyau n’ont pas d’impact significatif sur les performances jusqu’à environ ~100k événements par seconde (ce qui suggère un coût de l’ordre de 100ns).
  • Le coût des kprobes dépend de leur emplacement dans la fonction noyau concernée
    • A l’entrée d’une fonction (ex: suivi des appels, lecture des paramètres), le coût est comparable à celui d’un tracepoint.
    • A la sortie d’une fonction (kretprobe), c’est environ 2x plus cher.
    • Au milieu d’une fonction, ça peut être plus cher encore (ex: boucles).
  • Les uprobes sont plus coûteuses puisqu’elles nécessitent un point d’arrêt, donc une interruption et un basculement vers le code noyau. On parle de ~13x le coût d’un tracepoint pour une uprobe à l’entrée d’une fonction et ~19x en sortie d’une fonction.
  • Les USDT probes sont implémentées via des uprobes et ont donc un coût similaire.

Conclusion

Voici un résumé des informations précédentes.

InstrumentationOù?Quand?Stable?Coût (Gregg2020)
tracepointNoyauCompilationOui~100ns
kprobe (entrée)NoyauExécutionNon~100ns
kretprobeNoyauExécutionNon~200ns
uprobe (entrée)App/LibExécutionNon~1.3µs
uretprobeApp/LibExécutionNon~1.9µs
USDTApp/LibCompilationOui= u(ret)probe

Notons un dernier point concernant les privilèges et la sécurité. En raison de la très grande dangerosité de l’instrumentation de code arbitraire (risque de ralentir fortement le système, de créer des récursions infinies, de dévoiler des informations qui devraient rester secrètes…), la création de kprobes et uprobes est réservée au super-utilisateur. Vous aurez donc besoin d’un peu d’aide de l’administrateur pour certaines commandes de ce TP.

En revanche, l’administrateur peut rendre ces “sondes” accessibles à l’ensemble des utilisateurs d’un groupe par une simple commande chown de ce style :

# Nécessite des privilèges administrateur !
sudo chown -R root:perf /sys/kernel/tracing/events/xyz

Instrumentation kprobe

Comme nous l’avons vu dans la section théorique, les kprobes sont une fonctionnalité du noyau Linux qui permet d’instrumenter n’importe quel point du code source noyau, pour suivre quand ce code est exécuté et éventuellement en extraire de l’information (paramètres, variables…).

Il va de soi que quand on commence à se livrer à ce genre de sorcellerie, la règle d’or du suivi de l’activité système que nous avons introduite précédemment est plus importante que jamais :

Tu ne provoqueras pas, une fois par événement système surveillé, un événement système que tu surveilles également.

Pensez donc toujours bien, avant d’instrumenter une fonction noyau, à vous demander si elle ne pourrait pas être utilisée par l’outil perf que vous allez invoquer. En cas de doute, privilégiez des outils aussi peu “actifs” que possible (ex: perf record plutôt que perf trace).

Choix de fonction

La commande perf probe --funcs permet d’afficher une liste des fonctions noyau instrumentables. Sont instrumentables les fonctions qui ne sont pas inlinée et où l’instrumentation n’a pas été désactivée manuellement par les développeurs (générallement parce qu’elles sont destinées à s’exécuter dans un contexte où l’instrumentation poserait problème, ex: gestionnaire d’interruptions).

perf probe --funcs
Début de la sortie de perf probe --funcs

Vous noterez l’utilisation d’un pager. Il ne serait pas judicieux pour moi d’inclure la liste complète, car à l’heure où ces lignes sont écrites (noyau Linux 5.14.21), elle fait 40819 entrées. Ce chiffre est à comparer aux quelques 1600 tracepoints exposés par le noyau, en gardant aussi à l’esprit le fait qu’une kprobe n’est pas non plus forcée de se situer au début ou à la fin d’une fonction noyau…

Comme avec perf list, il est possible de filtrer la liste de fonctions émise par perf probe --funcs en ajoutant un motif shell. Par exemple, on peut n’afficher que les fonctions qui ont trait aux entrées/sorties asynchrones “à l’ancienne” (Linux AIO) :

perf probe --funcs '*aio*'

Fonctions d’E/S Linux AIO instrumentables

Instrumentation de l’appel d’une fonction

Pour illustrer l’intérêt des kprobes, nous allons maintenant essayer de surveiller le trafic TCP reçu par le serveur avec perf trace. Il n’y a pas de tracepoint adapté à l’heure où ces lignes sont écrites :

perf list 'tcp:*'

Tracepoints TCP fournis par le noyau linux

En effet, seul le tracepoint tcp:tcp_probe permet de surveiller le trafic TCP “normal”, et il ne fait pas de distinction entre le trafic entrant et sortant. On ne peut donc pas l’utiliser avec perf trace, puisque chaque sortie de perf trace générerait du trafic réseau pour l’envoi via notre connexion SSH, trafic réseau qui serait lui-même détecté par perf trace, et on aurait donc une boucle infinie.

A la place, nous allons instrumenter la fonction tcp_recvmsg() du noyau, qui est appelée lorsque du trafic TCP est reçu alors qu’un processus utilisateur attend des données sur un socket associé. Pour plus d’informations sur l’implémentation de la pile réseau du noyau Linux, vous pourrez vous référer à cette page quand vous aurez accès à Internet.

Signalez à l’administrateur de srv-calcul-ambulant que vous êtes arrivés à ce point du TP pour qu’il mette en place l’instrumentation et vous y donne accès si ce n’est pas déjà fait. Pour votre information, il procèdera comme suit :

# Nécessite des privilèges administrateur !
sudo perf probe tcp_recvmsg \
&& sudo chown -R root:perf /sys/kernel/tracing/events/probe

Notez que vous pouvez consulter la liste des instrumentations actives avec l’option --list de perf probe, qui s’abbrévie en -l, et que celles-ci apparaissent également dans perf list tracepoint.

Exercice : Utilisez perf trace pour faire un suivi de l’événement probe:tcp_recvmsg nouvellement créé, et observez ce qui se passe quand vous saisissez du texte dans votre terminal. Notez que ce faisant, vous serez aussi sensible à l’activité réseau issue des autres utilisateurs du serveur.

Capture de variables

A ce stade, nous ne pouvons pas différencier qui est responsable de quel trafic réseau. Le champ __probe_ip affiché par perf trace ne désigne que le pointeur d’instruction, qui sera toujours le même pour un appel de fonction ordinaire.

En revanche, avec une kprobe plus sophistiquée, il nous est possible d’enregistrer les paramètres et variables locales de la fonction, et ainsi en savoir plus sur les conditions dans lesquelles elle est appelée. Voyons la liste des variables accessibles :

perf probe --vars tcp_recvmsg

Variables accessibles de tcp_recvmsg()

Le pointeur de socket sk semble être un bon début : il sera différent pour chaque connexion réseau. Nous allons donc l’utiliser pour raffiner notre suivi.

Vous pouvez afficher également les (nombreuses) variables globales accessibles depuis le point du code source que vous êtes en train d’instrumenter en ajoutant l’option --externs avant --vars.

Signalez à l’administrateur de srv-calcul-ambulant que vous êtes arrivés à ce point du TP pour qu’il mette en place l’instrumentation et vous y donne accès si ce n’est pas déjà fait. Pour votre information, il procèdera comme suit :

# Nécessite des privilèges administrateur !
sudo perf probe tcp_recvmsg_sk=tcp_recvmsg sk \
&& sudo chown -R root:perf /sys/kernel/tracing/events/probe

Notez la nouvelle syntaxe utilisée. La syntaxe A=B permet de définir une probe appelée A qui surveille la fonction B, et les variables qu’on souhaite surveiller (“arguments” dans le jargon de perf probe) sont indiquées après un espace.

Exercice : Reprenez l’exercice précédent avec la probe tcp_recvmsg_sk nouvellement créée, et utilisez l’option --filter de perf trace pour ne plus observer que les événements TCP issus de votre propre connexion SSH.

Attention, la sortie de perf trace affiche les pointeurs comme des addresses entières signées, alors que l’option --filter attend un entier non signé. Vous pouvez faire la conversion avec la commande printf "0x%x\n" <nombre négatif à convertir>.

Notez qu’une variante simple de cette technique vous permettrait d’exclure le trafic TCP issu de votre propre connexion SSH, et ainsi d’instrumenter impunément la fonction réciproque tcp_sendmsg() qui est appelée à chaque envoi de données sur le réseau via un socket, sans créer une boucle infinie liée aux sorties de perf trace

Autres emplacements

Jusqu’à présent, pour simplifier les exemples, nos kprobes se sont toujours déclenchées au moment de l’entrée dans une fonction. Mais si nous le souhaitons, il est possible d’injecter une kprobe à peu près n’importe où dans le code de la fonction noyau, ou bien de détecter quand on sort de cette fonction et quelle est la valeur de retour émise à ce moment-là (on parlera alors de kretprobe).

Il est possible d’afficher le code source d’une fonction avec un accent visuel sur les lignes de code qui peuvent être instrumentées via l’option --line=<fonction> de perf probe, qui s’abbrévie en -L :

perf probe --line tcp_recvmsg

Lignes de code instrumentables de tcp_recvmsg()

On voit qu’il n’est pas possible de demander l’arrêt à certaines lignes du code, affichées en bleu. C’est lié au fait que les kprobe travaillant au niveau du binaire compilé, elles doivent être placées au niveau d’une instruction assembleur, or en présence d’optimisations une ligne de code n’est pas forcément clairement associée à une instruction assembleur.

Sur la base du code source affiché ci-dessus, on pourrait par exemple vouloir s’intéresser aux points du code suivant :

  • La ligne 20 représente le cas où on a effectué un cycle lock/unlock normal et s’apprête à exécuter éventuellement des fonctionnalités optionnelles contrôlées par le champ de bits cmsg_flags.
    • En surveillant la valeur de ce champ de bits, on peut savoir lesquelles de ces fonctionnalités seront utilisées ou pas.
  • La sortie de la fonction, qu’elle ait réussi ou échoué (selon une convention habituelle dans le monde UNIX, l’échec est signalé par un code de retour négatif).
    • En surveillant la valeur du champs de bits flags, on peut savoir si l’échec, le cas échéant, est survenu en raison de la condition flags & MSG_ERRQUEUE de la ligne 6 ou bien à l’intérieur de la fonction tcp_recvmsg_locked appelée ligne 15.

En croisant ces deux informations, il est possible d’étudier de façon différenciée les cas où la fonction réussit et ceux où elle échoue.

Signalez à l’administrateur de srv-calcul-ambulant que vous êtes arrivés à ce point du TP pour qu’il mette en place l’instrumentation et vous y donne accès si ce n’est pas déjà fait. Pour votre information, il procèdera comme suit :

# Nécessite des privilèges administrateur !
sudo perf probe tcp_recvmsg_sk_flags=tcp_recvmsg sk flags:x \
&& sudo perf probe tcp_recvmsg_sk_cmsg=tcp_recvmsg:20 sk cmsg_flags:x \
&& sudo perf probe tcp_recvmsg_retval=tcp_recvmsg%return '$retval' \
&& sudo chown -R root:perf /sys/kernel/tracing/events/probe

Notez la nouvelle syntaxe utilisée ici:

  • On peut suivre le nom d’une fonction de : et un nombre pour indiquer qu’on veut instrumenter une certaine ligne du code, relativement au début de la fonction.
    • Il est aussi possible d’utiliser cette syntaxe pour instrumenter une certaine ligne d’un fichier source, par exemple fichier.cpp:123.
  • On peut utiliser des modificateurs comme :x pour contrôler l’affichage des variables extraites. Ici, le modificateur x permet de forcer un affichage hexadécimal, ce qui est plus pertinent pour les données de type “flags”.
  • On peut instrumenter la sortie de la fonction avec la syntaxe %return à la fin du nom de la fonction, et utiliser la valeur spéciale $retval pour extraire le résultat renvoyé. Cette dernière nécessite un échappement car le signe $ serait sinon (mal) interprété par le shell.
    • Attention: le noyau ajoute automatiquement __return au nom des kretprobe. Ceci vise à permettre d’enchaîner perf probe xyz et perf probe xyz%return sans rencontrer d’erreur comme quoi le nom de kprobe “xyz” est déjà utilisé. La kprobe définie ci-dessus s’appelle donc tcp_recvmsg_retval__return.

Exercice : Utilisez les trois sondes définies ci-dessus (tcp_recvmsg_sk_flags, tcp_recvmsg_sk_cmsg et tcp_recvmsg_retval__return) pour en savoir plus sur la façon dont la fonction tcp_recvmsg s’exécute en pratique.

Pour une raison mystérieuse, au 13 octobre 2022, perf trace rencontre quelques difficultés avec ces sondes et n’affiche pas toutes les variables extraites. Je vous recommande donc d’utiliser à la place perf record -a suivi de perf script.

Nous reviendrons plus loin sur ce que fait perf script, mais à ce stade vous pouvez retenir que si on la lance sans argument, elle ouvre le fichier perf.data produit par perf record et affiche son contenu dans l’ordre chronologique.

Conclusion

Avec perf probe, il est possible d’instrumenter presque n’importe quel point du code source du noyau linux pour savoir quand est-ce qu’il est exécuté et extraire des valeurs de paramètres, résultats, et variables globales et locales.

Nous allons voir par la suite que ces fonctionnalités d’instrumentation fonctionnent presque pareil pour du code utilisateur (exécutables et bibliothèques).

Mentionnons pour conclure qu’on peut supprimer une kprobe avec la commande administrateur sudo perf probe --del <nom>, et qu’il est aussi possible de spécifier un point d’arrêt par une recherche textuelle dans le code source (syntaxe ;<glob>), ce qui permet d’écrire des scripts un petit peu plus pérennes par rapport aux évolutions futures du noyau Linux. Consultez perf help probe pour plus d’informations.

Instrumentation uprobe

Dans la section précédente, nous avons vu comment il est possible d’instrumenter le code du noyau Linux pour en extraire des informations. Nous allons maintenant voir comment une variante de cette instrumentation, les uprobes, peut être utilisée pour extraire des informations du code utilisateur : exécutables, bibliothèques…

Utilisation simple des uprobes

Dans l’ensemble, l’utilisation de perf probe sur un binaire utilisateur est très similaire à celle sur le noyau Linux. Il suffit de rajouter un paramètre --exec, qui s’abbrévie en -x, pour désigner le binaire cible. Par exemple, on peut énumérer les fonctions instrumentables avec --funcs/-F.

perf probe -x /bin/bash --funcs
Début de la sortie de perf probe -x /bin/bash --funcs

Si le code source de l’exécutable est disponible (ce qui est le cas pour bash sur srv-calcul-ambulant), on peut afficher les lignes instrumentables d’une fonction avec l’option --line/-L :

perf probe -x /bin/bash --line absolute_pathname

Code source de la fonction absolute_pathname de bash

Si des symboles de déboguages sont disponibles, on peut aussi toujours afficher le nom des variables accessibles par l’instrumentation avec --vars/-V :

perf probe -x /bin/bash --vars main

Variables de la fonction main de bash

Bref, vous l’aurez compris, à un paramètre -x près, l’utilisation de perf probe pour les uprobes est quasiment identique à celle pour les kprobes. Notez juste les préconditions : pour avoir toutes les fonctionalités, il faut avoir accès à la fois à des symboles de déboguage et au code source du binaire, ce qui est rarement le cas par défaut et nécessite un peu de préparation de l’administrateur.

Maintenant, puisqu’on parle d’administrateur, invitez-le justement à activer une instrumentation au niveau de la fonction main() de bash avec la commande suivante :

# Nécessite des privilèges administrateur !
sudo perf probe -x /bin/bash main argc argv[0]:string \
&& sudo chown -R root:perf /sys/kernel/tracing/events/probe_bash

Ce qui est l’occasion d’aborder quelques nouveaux points de syntaxe :

  • Il est possible à perf probe d’accéder aux éléments d’un tableau C, par contre les pointeurs char* ne sont pas automatiquement traités comme des chaînes de caractère, il faut explicitement le demander avec le suffixe :string.
  • Pour limiter les collisions de noms, les sondes sont regroupées en groupes. Par défaut, les kprobes appartiennent au groupe probe, et les uprobes à un groupe appelé probe_xyz où xyz est le nom du binaire instrumenté. Il est possible d’utiliser un nom de groupe personnalisé si on le souhaite, ce qui est par exemple utile si plusieurs personnes avec des droits administrateurs veulent utiliser perf probe sur le même binaire en même temps sans se marcher sur les pieds.

Exercice : En surveillant la sonde probe_bash:main nouvellement créée avec perf trace, essayez de lancer bash quelques fois en variant le chemin d’accès utilisé (détection automatique via PATH vs absolu…) et en ajoutant des paramètres, et notez comment chaque nouvelle exécution de bash est désormais suivie par perf trace.

Considérations spécifiques aux bibliothèques GNU

Nous l’avons vu, l’utilisation de base des uprobes est, dans l’ensemble, très similaire à celle des kprobes. Toutefois, quand on commence à les utiliser en pratique, on remarque qu’on a besoin de prendre des précautions qui ne sont pas souvent nécessaires avec le code noyau.

Pour être plus précis, lorsqu’on utilise des uprobes, il est très tentant d’instrumenter des bibliothèques de base telles que la libc, la libstdc++ (bibliothèque standard C++ de GCC), la libm (implémentation des fonctions mathématiques comme sin, cos…), ou la libpthread (implémentation des primitives de synchronisation comme les mutexes). Mais si on essaie, on se rendra rapidement compte qu’il ne suffit pas forcément d’instrumenter la fonction ayant le nom le plus évident (ex: memcpy dans la libc), pour les raisons suivantes :

  • Les compilateurs tendent à inliner une partie de l’implémentation de ces fonctions au sein du programme appelant, et donc la fonction en elle-même n’est presque jamais appelée, seule des sous-fonctions utilitaires internes sont éventuellement appelées.
  • Les bibliothèques GNU utilisent des techniques compliquées pour optimiser la compatibilité binaire des programmes et le compromis portabilité/performance, et l’analyse des binaires de perf probe a du mal à voir à travers ces couches d’abstraction.

La manière la plus simple de contourner ces aléas est d’utiliser un profileur CPU pour repérer le nom de la fonction qui est réellement appelée par le binaire final, et instrumenter celle-là. Mais parfois, on aura aussi la chance de disposer d’instrumentation statique USDT prête à l’emploi, ce que nous allons aborder dans la section suivante.

Userland Statically Defined Tracing (USDT)

Dans ce chapitre, nous sommes partis des tracepoints exposés volontairement par le noyau Linux, et nous avons vu comment perf probe nous permettait d’instrumenter du code noyau arbitraire (kprobe) ou du code utilisateur arbitraire (uprobe).

Les personnes attentives à la combinatoire auront remarqué qu’il y a une configuration que nous n’avons pas encore explorée, c’est celle d’une instrumentation exposée volontairement par les binaires utilisateurs. C’est le propos de l’instrumentation USDT que nous allons explorer maintenant.

Recherche d’instrumentation USDT

perf n’est malheureusement pas capable de découvrir toute l’instrumentation USDT disponible sur le système par lui-même, car cela impliquerait de rechercher et ouvrir tous les binaires du système de fichier à chaque appel à perf list, ce qui aurait un coût inacceptable.

A la place, perf conserve un cache de l’instrumentation observée dans les binaires qu’on l’a amené à ouvrir, par exemple en faisant l’analyse de piles d’appel acquises via --call-graph=dwarf.

Il est aussi possible d’ajouter manuellement des binaires à ce cache avec la commande perf buildid-cache, ce que nous allons faire maintenant pour toutes les bibliothèques de bases mentionnées à la fin de la section sur les uprobes, plus le chargeur de binaires ld :

perf buildid-cache -a /lib64/libc.so.6 \
&& perf buildid-cache -a /lib64/libm.so.6 \
&& perf buildid-cache -a /lib64/libpthread.so.0 \
&& perf buildid-cache -a /usr/lib64/libstdc++.so.6 \
&& perf buildid-cache -a /lib64/ld-linux-x86-64.so.2

(Vous noterez que chacune de ces commandes affiche un pager vide qui doit être quitté avec la touche “q”. Si vous trouvez un moyen de désactiver temporairement ce pager, je suis preneur.)

Ceci étant fait, l’instrumentation USDT exposée par ces binaires apparaîtra dans perf list :

perf list sdt
Sortie de perf list sdt

On voit que les bibliothèques GNU exposent pas mal d’instrumentation de ce type autour de la gestion mémoire, de la gestion d’exceptions (via throw/catch ou setjmp/longjmp), de pièges de performance liés à la libm, ou de la manipulation et synchronisation de threads via pthread.

Activation de l’instrumentation

Si vous regardez la liste ci-dessus, vous constaterez que certains de ces “points d’instrumentation” sont sujets à être fréquemment empruntés, notamment ceux ayant trait à la synchronisation de threads. Il ne serait pas acceptable d’y payer en permanence le coût en performance d’une instrumentation quand celle-ci n’est pas utilisée.

L’instrumentation USDT est donc désactivée par défaut et doit être activée explicitement. Et comme l’implémentation actuelle utilise des uprobes, cette instrumentation doit être effectuée par l’administrateur du système, via les opérations suivantes :

  1. Remplir le buildid-cache de root en relançant les commandes ci-dessus pour cet utilisateur (typiquement via sudo).
  2. Activer l’instrumentation USDT souhaitée avec la commande perf probe, avec la syntaxe utilisée par perf list.

Comme nous pouvons parfois être amené à activer un grand nombre de points d’instrumentation à fins exploratoires, c’est le bon moment pour mentionner qu’on peut passer à perf probe des motifs shell du style sdt_libpthread:*.

Invitez maintenant l’administrateur à activer l’instrumentation de la libpthread avec le jeu de commandes suivantes :

# Nécessite des privilèges administrateur !
sudo perf buildid-cache -a /lib64/libpthread.so.0 \
&& sudo perf probe sdt_libpthread:* \
&& sudo chown -R root:perf /sys/kernel/tracing/events/sdt_libpthread/

Exercice : Commencez par évaluer la fréquence des différents événements exposés par libpthread avec perf stat, puis prenez un événement suffisamment peu fréquent et suivez ses occurences avec perf trace.

perf script

Arrivé au terme de la partie sur perf trace, vous aurez peut-être été étonné de voir que je passe très vite sur la possibilité d’enregistrer et rejouer des traces d’appels système avec perf trace.

La raison est qu’il existe un outil dédié à l’affichage détaillé de données mesurées par perf, qui est plus général et que vous serez donc davantage amené à utiliser en pratique.

Cet outil, vous l’aurez deviné, c’est perf script. Et il doit son nom curieux au fait qu’il peut aussi être utilisé pour simplifier l’écriture et l’utilisation de scripts manipulant des fichiers perf.data.

Premier contact

Pour illustrer la première fonction de perf script (afficher, sous une forme lisible par des êtres humains, le contenu d’un fichier perf.data), nous allons commencer doucement en analysant le fichier perf.data produit durant l’étude de perf annotate :

cd ~/tp-perf \
&& srun make -j$(nproc) \
&& srun --pty perf record ./scale.bin 2048 10000000 \
&& perf script
Sortie de perf script

Pour chaque échantillon enregistré, perf script nous affiche…

  • Le nom du programme qui s’exécutait à ce moment-là
  • L’identifiant de processus (PID) associé
  • Le moment où l’enregistrement a été effectué, en secondes depuis le démarrage du système.
  • L’événement étudié (par défault le passage des cycles processeur) et le nombre de ces événements qui sont survenus depuis le dernier échantillon.
  • Le pointeur d’instruction auquel se trouvait le programme au moment où la mesure a été effectuée, suivi d’une analyse de ce dernier en termes…
    • Du symbole (= la fonction) dans lequel on se trouvait, et du décalage en octets de l’instruction active par rapport à ce symbole.
    • Du fichier binaire dont est originaire ce symbole.

Ce n’est qu’un sous-ensemble des données qui peuvent être affichées. Avec l’option --fields/-F, on peut demander l’affichage d’autres informations comme le numéro de thread (tid), le numéro de CPU (cpu), ou une estimation de la ligne de code source associée basée sur les symboles de déboguage (srcline). Consultez perf help script pour plus de détails. Mais pour cette première approche, nous allons nous contenter des informations affichées par défaut.

On constate que durant les premières millisecondes enregistrées, on observe l’activité de perf lui-même, et pas encore celle du programme enregistré. Il sera lancé par un appel système exec une fois la configuration de perf record terminée.

Durant cette brève phase, on note un petit couac de mesure (pointeur d’instruction à l’addresse 0, ce qui est peu plausible), ainsi que ce qui ressemble à une première calibration du nombre de cycles processeur que perf doit compter entre chaque échantillon pour atteindre la fréquence d’échantillonnage souhaitée (4 kHz pour l’événement cycles par défaut dans perf 5.3).

Puis l’on entre dans l’exécution du programme scale.bin proprement dit, où l’on voit les données brutes qui ont été traitées par perf annotate pour produire du code source annoté. On note au passage que la calibration du nombre de cycles n’est pas complètement terminée à ce stade, et va se raffiner progressivement au début de l’exécution du programme.

Une première utilité de perf script est d’examiner ces données brutes lorsqu’un outil de plus haut niveau, comme perf annotate ou perf report, produit des résultats qui semblent incohérents.

Exercice : Répétez cette analyse avec des données contenant des graphes d’appels DWARF, comme celles produites dans l’étude de l’exemple TPhysics. Notez que cette visualisation se prête plus facilement à l’étude de piles d’appels brisées, dans l’optique de signaler un bug par exemple.

Intel Processor Trace (Intel PT)

Intel Processor Trace, en abrégé Intel PT, est une fonctionnalité intégrée aux processeurs Intel depuis la génération Broadwell qui permet de suivre de façon exhaustive l’exécution d’un programme, à l’instruction assembleur près, ainsi que certains événements CPU associés (branchements, gestion d’énergie, transactions, gestion des interruptions, entrée/sortie d’une VM…).

Comme on peut l’imaginer, le volume de données produit est gigantesque même si elles sont compressées (on parle de plusieurs centaines de Mo par seconde et par coeur CPU). Le coût d’instrumentation peut être conséquent, et l’analyse peut aussi devenir coûteuse (100-1000x plus lente que l’acquisition). Ce type d’étude doit donc être réservé à des programmes dont l’exécution est très rapide, ou bien à des instants courts de l’exécution d’un programme complexe.

Dans cette partie, nous verrons comment acquérir ces données avec perf record, les afficher avec perf script, et les intégrer à une visualisation perf report.

Acquisition

Les événements Intel PT sont accessibles via l’événement intel_pt//. La syntaxe curieuse //, que nous n’avons pas abordée auparavant, permet de spécifier des paramètres pour l’acquisition, par exemple le type d’horloge utilisé. Elle est aussi utilisée quand on interroge des compteurs de performance du CPU dont le support n’a pas encore été implémenté au sein du duo perf+noyau utilisé, typiquement quand on travaille avec un noyau Linux plus ancien que son CPU.

Quoiqu’il en soit, les paramètres par défaut seront suffisant pour cette introduction. Commençons donc par faire une première analyse du code d’exemple scale.bin, configuré avec des paramètres permettant d’avoir…

  • Une boucle interne courte (64 multiplications -> 8 opérations AVX)
  • Un temps d’exécution court (quelques dizaines de millisecondes)

Pour limiter le volume de données produites, nous ne considérerons que l’activité CPU applicative en ignorant celle du noyau, ce qu’on peut faire avec l’habituel suffixe u. Et nous allons nous restreindre à une exécution séquentielle pour cet exemple afin que la sortie soit plus facile à interpréter.

cd ~/tp-perf \
&& srun make -j$(nproc) \
&& srun --pty --hint=nomultithread \
   perf record -e intel_pt//u \
   ./scale.bin 64 10000000

Affichage détaillé via perf script

En principe, on peut ensuite afficher directement la liste des instructions exécutées avec perf script --insn-trace --xed

perf script --insn-trace --xed
Début de la sortie de perf script --insn-trace --xed

…mais avant d’arriver à l’exécution du code arithmétique qui nous intéresse, il nous faudrait d’abord passer par des millions de lignes d’assembleur exécuté par le chargeur de programme ld et l’initialisation de la bibliothèque standard. Ce n’est clairement pas viable.

A la place, nous allons commencer par la vue de plus haut niveau fournie par perf script --call-trace, qui indique notamment quelles fonctions ont été appelées, quelles fonctions ces fonctions ont appelé, et ainsi de suite. Un arbre des appels de fonction visibles du CPU (non inlinés), donc.

perf script --call-trace
Début de la sortie de perf script --call-trace

Cette sortie est encore très imposante (13201 lignes au moment où ces lignes sont écrites), car ld et la bibliothèque standard font vraiment beaucoup de choses au démarrage du programme. Mais nous pouvons exploiter ici le fait qu’il se passe très peu de choses au moment de l’arrêt du programme (peu de variables globales avec des destructeurs), sauter directement à la fin de la sortie avec la touche “Fin” de notre clavier, et remonter avec la touche “page précédente” jusqu’à la fonction main, qui n’est pas bien loin en comparaison.

Sortie de perf script --call-trace autour de main

Sur des exécutions plus complexes où cette astuce ne marcherait plus, il faudrait utiliser un outil d’analyse de texte (grep, script python…) pour repérer la région qui nous intéresse.

Notez, au sein de la fonction main, une longue période de temps sans appel à une fonction (la boucle de calcul), où l’on ne voit que des “paquets PSB” (points de repère utilisés pour l’avance rapide dans le fichier et la récupération de données partiellement corrompues), des événements de changement de fréquence d’horloge CPU, et des rappels réguliers du nom de la fonction active (main) dont la raison d’être n’est pas claire pour l’auteur de ce TP.

Une fois la fonction main repérée, nous pouvons repérer la valeur de l’horloge juste avant qu’on rentre dans la boucle de calcul (ce qu’on repère par une longue période de temps sans appel à d’autres fonctions) et juste après la fin de la boucle de calcul (ce qu’on repère par la libération de structures de données avec free). Puis on peut relancer la vue détaillée perf script --insn-trace --xed en ciblant cette fenêtre de temps avec l’option --time :

# ATTENTION: Adaptez la fenêtre de temps à vos données !
perf script --insn-trace --xed --time 402890.894663115,402890.961054471

L’horloge TSC étant relativement peu précise quand on travaille à l’échelle de l’instruction CPU unique, il faut scroller un peu pour arriver à la fin de l’initialisation des vecteurs et au début de la boucle de calcul, mais on finit quand même par trouver notre bonheur :

Sortie de perf script --insn-trace --xed autour du calcul

(Notez qu’il est possible que les instructions diffèrent un peu sur votre écran si vous avez fait les exercices du TP sur perf annotate)

Exercice : Répétez le processus en utilisant cette fois un nombre d’itérations de boucle interne (premier paramètre de l’exemple scale.bin) qui n’est pas multiple de la taille de vecteur SIMD (8 flottants en mode AVX), et observez comment cela affecte les instructions exécutées. Attention à ne pas trop augmenter la taille du calcul ce faisant…

Affichages simplifiés

Nous avons vu précédemment qu’il est possible d’afficher un résumé des données issues de Intel PT qui se limite aux appels de fonction non inlinés et à quelques autres événements avec l’option --call-trace. Ce n’est pas le seul type d’affichage simplifié disponible.

Tout d’abord, il existe une variant de --call-trace appelée --call-ret-trace qui permet d’afficher une ligne par retour d’une fonction en plus des lignes d’appels de fonction. Cela peut être utile quand une fonction appelle transitivement beaucoup d’autres fonctions, ainsi que pour interpréter des traces exotiques contenant des appels de fonction “sans retour” (ex: coroutines).

perf script --call-ret-trace
Début de la sortie de perf script --call-ret-trace

Notez les événements tr start et tr end qui représentent les moments où l’enregistrement démarre et s’arrête, typiquement en raison d’un changement de contexte (rappelez-vous que nous n’enregistrons pas l’activité du noyau dans notre exemple).


La plupart des autres affichages simplifiés disponibles passent par l’option --itrace, documentée dans la page de man perf-intel-pt, rubrique PERF SCRIPT, sous-rubrique New --itrace option.

Celle-ci permet entre autres de se limiter à l’affichage des instructions i, des branchements b (ou plus spécifiquement des appels c et/ou retours r de fonction), des transactions x (au sens Intel TSX), et des événements de gestion de l’énergie p (typiquement changement de fréquence d’horloge + paquets PSB). Ces lettres peuvent être combinées pour afficher plusieurs de ces événements sans afficher la totalité des événements.

On peut également demander, pour les événements sélectionnés, demander d’afficher des piles d’appels g pour mieux situer où l’événement se produit, et demander avec s<N><N> est à remplacer par un nombre que les N premiers événements soient ignorés (typiquement pour ne pas décoder et afficher la phase d’initialisation). Encore une fois, cette liste n’est pas exhaustive.


Le possibilité d’afficher chaque instruction enregistrée avec i peut sembler a peu près redondante par rapport à l’option exhaustive --insn-trace, mais elle prend tout son sens quand on la combine avec une sous-option qui permet de n’afficher qu’un échantillon des instructions enregistrées, extrait à la fréquence de son choix.

Par exemple, --itrace=i1ms n’affiche qu’une instruction toutes les millisecondes. Les autres unités disponibles sont us pour les microsecondes, ns pour les nanosecondes, t à chaque pas de l’horloge utilisée, et i pour afficher une instruction toutes les N instructions.

Cette fonctionnalité permet de simuler l’effet d’un profilage CPU à une fréquence bien supérieure au maximum autorisé par perf record (quelques dizaines de kHz), et ainsi d’obtenir des données d’une qualité statistique inaccessible par d’autres moyens. Ce qui a particulièrement du sens quand on visualise les données non pas avec perf script, mais avec perf report.

Intégration à perf report

Les événements générés par une certaine configuration de --itrace peuvent être visualisés non pas sous forme de séquence temporelle, mais d’histogramme/profil, en passant cette option à perf report. En voici un exemple qui affiche un rapport sur les instructions de branchement :

perf report --itrace=b

Après un temps de traitement, perf nous invite à choisir l’événement qu’on veut afficher avec une invite où il n’y a qu’un seul choix raisonnable. Gageons que l’ergonomie sera améliorée un jour…

Choix d’événement

…puis, si on sélectionne l’événement “branches”, on se retrouve face à une liste des fonctions du programme triée par nombres de branchements…

Choix de fonction

…et on peut annoter main pour voir les branchements liés à la boucle de calcul :

Répartition des branches dans l'assembleur de main

Tout se passe donc comme si on avait fait un perf record -e branches sauf que cette fois les données sont une représentation exacte de l’exécution du programme au lieu d’être une estimation statistique basée sur un échantillonage.

Exercice : Avec les instructions, la possibilité d’échantillonner à la fréquence temporelle de son choix permet d’avoir un traitement plus rapide tout en conservant une qualité statistique du résultat très supérieure à ce qui peut être obtenu par l’utilisation normale de perf record.

Utilisez la syntaxe --itrace=i<période> abordée ci-dessus pour obtenir un profil en nombre d’instructions exécutées de la boucle de calcul simulant une fréquence d’échantillonage de 1 MHz (1µs entre chaque échantillon). Remarquez l’excellente qualité statistique du résultat (prévisible en l’occurence, donc vérifiable), bien que l’exécution enregistrée ait duré moins de 100ms : elle est équivalente à celle qui aurait été obtenue en échantillonnant à 10kHz pendant 10s !

Paramètres d’acquisition

Nous arrivons à la fin de ce tour d’horizon de ce qu’il est possible de faire avec Intel PT et perf. Pour conclure, explorons un peu quels genre de paramètres peuvent être passés en argument de l’événement intel_pt// (entre les deux signes /).

La syntaxe générale est de donner le nom de chaque paramètre souhaité, éventuellement suivi du signe = et de la valeur qu’on veut lui donner (sinon une valeur par défaut spécifiée dans la documentation), puis on continue avec les autres paramètres éventuels en séparant par des virgules. Par exemple, avec cette syntaxe, les paramètres par défauts s’écrivent : intel_pt/tsc,noretcomp=0/ (activer l’option tsc, donner la valeur 0 à l’option noretcomp).

Les paramètres disponibles dépendent du modèle de CPU que l’on utilise. On peut les répartir en quelques grandes catégories :

  • Le choix de l’horloge utilisé. On l’a vu, l’horloge TSC a une granularité un peu grossière pour du profilage à la granularité de l’instruction CPU unique, c’est pourquoi d’autres horloges plus précises sont donc disponibles sur certains modèles de CPU :
    • L’horloge cyc, si elle est disponible, est précise au cycle CPU près
    • L’horloge mtc, si elle est disponible, offre une précision intermédiaire entre cyc et tsc.
  • La désactivation de certaines mesures :
    • Le relevé d’horloge (pas nécessaire si on veut juste la séquence d’instructions)
    • Le suivi des branches
    • L’instruction PTWRITE (qui permet au programme en cours d’exécution d’injecter des données de son choix dans le flux Intel PT)
    • Les événements de gestion de l’énergie (ex : changements de fréquence CPU)
  • La fréquence à laquelle certains relevés sont fait, notamment celui des horloges non-TSC et celui des paquets PSB qui représentent des points de départ possibles pour le décodage et des points de récupération en cas d’erreur d’acquisition.
  • Les options noretcomp et fup_on_ptw qui permettent d’enregistrer des détails supplémentaires, respectivement pour améliorer la résilience aux erreurs d’acquisition et pour mieux localiser la position des instructions PTWRITE dans le flux d’instruction.

Pour plus de détails sur ces paramètres et la détection de leur présence ou non sur le CPU utilisé, voir la page de man perf-intel-pt, rubrique PERF RECORD, sous-rubrique config terms.

Plus généralement, la lecture de l’ensemble de cette page de man est recommandée pour plus d’informations sur Intel PT et son intégration à perf, cette section de TP n’étant qu’une introduction aux possibilités offertes par l’outil.

Scripting

Nous arrivons enfin à la fonctionnalité qui a donné son nom à la commande perf script, à savoir la possibilité de manipuler les données mesurées par perf avec du code.

Bien qu’il serait, en principe, possible de le faire juste avec la fonctionnalité de base de perf script qui décrit sous forme textuelle les événements stockés dans un fichier perf.data, ce serait insatisfaisant pour plusieurs raisons :

  • Il faut disposer d’un fichier perf.data, ce qui exclut les analyses “temps réel” sauf à alterner rapidement entre des exécutions de perf record et perf script.
  • On gaspillerait nos cycles CPU à convertir les données issues du fichier perf.data en format texte avec perf script, puis à réinterpréter cette sortie texte sous une forme utilisable par notre programme au sein de celui-ci.

Il est donc préférable, quand c’est possible, d’utiliser une vraie API. En dehors de l’API noyau perf_events() utilisée par tous les outils perf, qui est accessible depuis tout langage de programmation pouvant invoquer une API C, deux APIs plus haut niveau sont actuellement utilisables via perf script, une pour Perl et une pour Python.

Utilisation de scripts

Nous pouvons commencer par explorer la panoplie de scripts distribués avec perf, certains n’ayant pas seulement de la valeur en tant qu’exemple :

perf script --list

Liste des scripts fournis avec perf

Ces scripts se divisent en deux catégories :

  • Les scripts d’analyse différée, qui peuvent fonctionner en deux temps :
    1. On enregistre des données avec perf script record <script> [<commande>].
      • Si une commande est spécifiée, elle est exécutée et des mesures sont prises sur le processus associé et ses enfants jusqu’à la fin de l’exécution (comme avec perf record <commande>, mais le choix des événements est automatique)
      • Si aucune commande n’est spécifiée, on fait des mesures à l’échelle du système entier (comme avec perf record -a). Il est possible de forcer ce mode d’acquisition même quand une commande est précisée avec l’option -a.
    2. On affiche les résultats avec perf script report <script>, suivi des arguments éventuels du scrips mentionnés dans la sortie de perf script --list.
  • Les scripts d’analyse temps réel, dont le nom se termine par “top”, et qu’on lance avec perf script <script> suivi des arguments éventuels.
    • Si on lance un script d’analyse différée avec la même syntaxe, perf effectue un cycle enregistrement/affichage sans créer de fichier intermédiaire (les données sont stockées en mémoire). Mais on ne peut pas spécifier d’arguments optionnels.

Voici un exemple d’analyse différée avec le script failed-syscalls, qui compte les appels système ayant échoués, à l’échelle du système entier si on ne passer pas de commande en argument :

perf script -a record failed-syscalls sleep 5 \
&& perf script report failed-syscalls

Exemple de perf script failed-syscalls

Et voici un exemple d’analyse temps réel avec le script rwtop, qui indique en temps réel quels processus transfèrent le plus grand volume de données via les appels système read() et write(), ainsi que les tailles de tampons utilisées lors des lectures :

perf script rwtop

Exemple de perf script rwtop

Notez que certains scripts peuvent avoir des dépendances extérieures non disponibles sur srv-calcul-ambulant, par exemple le script sched-migration a une interface graphique WxPython.

Ecriture de scripts

La liste des noms de langages reconnus peut être affichée avec l’option --script lang. Un fichier dont le nom se termine par un de ces noms sera traité comme étant écrit dans le langage associé.

perf script --script lang

Exemple de perf script rwtop

Et la documentation pour un langage donné est contenue dans la page de man perf-script-<langage>, par exemple perf-script-python pour Python. Elle commence par un tutoriel, suivi de documentation de référence.

On peut générer un squelette de code avec l’option --gen-script=<langage>, puis une fois le code écrit, on peut indiquer à perf script d’utiliser notre script avec l’option --script=<fichier>.

Pour en apprendre plus sur l’écriture de script, je vous invite à effectuer le tutoriel contenu dans la page de man associée au langage que vous préférez.

perf iostat

Toutes les commandes perf que nous avons étudiées jusqu’à présent ont pour fonction d’étudier le comportement du CPU ou du code qui s’y exécute. Mais perf iostat, elle, étudie le volume des échanges entre le CPU et les périphériques (stockage, GPU, réseau…) via le bus PCI-express (PCIe).

C’est donc par exemple un outil approprié pour savoir si une interconnexion CPU-périphérique est saturée, pour peu que l’on sache quel est le débit crète attendu (ex : PCIe 3.0 x16 = 16 Go/s).

Tout d’abord, on peut obtenir une liste des ports PCIe racine que perf iostat peut interroger :

perf iostat list

Ports PCIe racine

Pour savoir quels périphériques sont raccordés à ces ports, on peut utiliser la commande /sbin/lspci -D. Le résultat contiendra un grand nombre de “périphériques” qui sont en réalité des composants internes du CPU, voici un affichage simplifié qui ne les inclut pas pour plus de clarté :

/sbin/lspci -D | grep -v 'Intel Corporation Sky Lake-E'

Liste de périphériques PCIe

L’adressage PCIe est construit de telle sorte que le numéro de bus (la 1ère paire de chiffres hexadécimaux dans l’adresse) d’un enfant est compris entre celui du port racine auquel il est raccordé et celui du port racine suivant, donc…

  • La carte graphique est raccordée au port racine 0000:20.
  • Le SSD système est raccordé au port racine 0000:2c.
  • Les autres périphériques (USB, SATA, réseau, audio…) sont raccordés au port racine 0000:00.
  • Le port racine 0000:14 n’est connecté qu’à des ressources internes au CPU.

Pour illustrer cela, commençons par écrire 1 Gio de zéros sur le SSD par blocs de 4 Mio en surveillant l’ensemble des ports PCIe racine :

srun --pty --exclusive \
    perf iostat \
    dd if=/dev/zero of=~/.Private/test.dump \
       bs=4M count=256 oflag=direct \
; rm ~/.Private/test.dump

Suivi d’une commande dd écrivant sur le SSD

On voit dans le rapport produit par perf iostat qu’aucun des ports PCIe racine n’a été sollicité de façon significative à l’exception de celui connecté au SSD.

Sur celui-ci, 1026 Mio de données ont été lus par le SSD depuis la RAM (ces données ayant été préalablement préparées par le CPU). On observe donc un petit surcroît de trafic lié au protocole de communication CPU-SSD, mais qui reste très modeste ici.

On voit aussi que les transferts de données utilisent la technique du DMA (Direct Memory Access), où c’est le périphérique de stockage qui fait l’essentiel du travail pour transférer les données. C’est le cas général sur un système moderne.

Pour ce qui concerne le débit, dans le cas présent, l’utilitaire dd le calcule pour nous. Mais un intérêt de perf iostat est que cet utilitaire est utilisable même sur des programmes qui ne sont pas instrumentés pour mesurer leur volume de données, en divisant le volume de données transféré par la durée de la mesure.

On voit en tout cas que le débit est de 2 Go/s, ce qui pourrait saturer l’interconnexion sur certaines carte mères (ports PCIe 3.0 x2), mais pas toutes (certaines ont des ports x4). Il faudrait donc avoir les spécifications de la carte mère et du SSD pour conclure, mais celles-ci n’ont malheureusement pas été fournies par HP pour srv-calcul-ambulant.

En revanche, si on fait la même expérience avec le disque dur…

srun --pty --exclusive \
    perf iostat \
    dd if=/dev/zero of=hdd/$USER/test.dump \
       bs=4M count=256 oflag=direct \
; rm /hdd/$USER/test.dump

Suivi d’une commande dd écrivant sur le disque dur

…on voit qu’on est très loin d’atteindre le débit minimal d’un port PCIe pour la génération 3.0, qui est de 1 Go/s (canal 1x). On peut donc conclure sans spécifications supplémentaires que c’est le disque dur qui limite les performances d’écriture ici, et pas l’interconnexion.

Mentionnons pour conclure que sur des systèmes où il y a beaucoup de ports PCIe racine (c’est le cas des système en rack, surtout quand ils sont multi-CPU), il peut être intéressant de n’en surveiller et n’en afficher que quelques uns. C’est possible de le faire en passant les numéros de port racine à perf iostat, avant la commande à surveiller, sous forme de liste séparée par des virgules (ex : perf iostat 0000:2c,0000:20 <commande>).

Exercice : En vous inspirant des commandes dd ci-dessus, créez une charge de travail qui copie 1 Gio de données du SSD au disque dur et surveillez-la avec perf iostat en restreignant l’affichage aux ports PCIe auxquels le SSD et le disque dur sont connectés. Pour plus de réalisme, vous pouvez générer des données aléatoires en utilisant /dev/random comme source plutôt que /dev/zero.

perf mem

Si vous avez traîné dans les cercles d’optimisation de performances logicielles, vous avez peut-être déjà croisé la phrase “cache rules everything around me”.

En-dehors d’une référence musicale, c’est aussi une réflexion sur le fait que plus le temps passe, plus l’écart de performances entre unités de calcul et bus mémoire se creuse. Ce qui veut dire que pour utiliser efficacement les premières, il faut de moins en moins utiliser le second, et donc tirer de mieux en mieux parti des mémoires cache.

Nous avons déjà vu quelques outils pour analyser l’utilisation qu’on en fait des caches “à gros grain” (les événements cache et leur suivi via perf stat et perf record). Mais quand on a vraiment envie d’aller dans le détail, ou face à un code particulièrement complexe, perf mem est plus puissant.

Là où les formes de perf stat et perf record que nous avons abordées se concentrent sur la fréquence des événements liés au cache et leur localisation dans le code, perf mem est focalisé sur les addresses mémoire auxquelles le code accède, et permet d’établir plus directement quelles données de l’application doivent en priorité être optimisées pour la localité de cache.

Utilisation de base

Commençons doucement avec notre exemple scale.cpp, dont la logique d’accès mémoire est apparente dans le code, ce qui permet de suivre plus facilement ce que fait perf mem. Pour la même raison, nous allons le lancer en mode séquentiel, sans hyperthreading.

cd ~/tp-perf \
&& srun make -j$(nproc) \
&& srun --pty --hint=nomultithread \
       perf mem record --ldlat=0 \
       ./scale.bin 64 1000000000 \
&& perf mem report

perf mem report affiche alors un menu qui nous permet d’afficher un profil des lectures…

Rapport sur les lectures

…et des écritures :

Rapport sur les écritures

Comment interpréter ces tables ?

  • La colonne “Overhead” indique quel pourcentage des échantillons mesurés par perf contenaient des accès mémoire avec les propriétés détaillées ensuite, avec pondération par la colonne “Local weight”.
  • La colonne “Samples” affiche la donnée brute de nombre d’échantillons. Elle n’apporte donc pas de nouvelle information par rapport à “Overhead” mais permet juste de s’assurer rapidement qu’on a une statistique suffisante.
  • La colonne “Local weight” attribue un coût relatif à l’accès mémoire, fonction entre autres choses de la latence d’accès à la donnée pour les lectures.
    • Notez que sur les processeurs Intel, on mesure les latences d’utilisation (nombre de cycles écoulés entre le moment où une donnée est demandée par le CPU et le moment où elle est utilisée) et pas seulement les latences de la hiérarchie mémoire (nombre de cycles requis pour rendre une donnée accessible par le CPU). C’est la raison pour laquelle on peut observer des poids très variables sur des codes moins simples.
  • La colonne “Memory Access” décrit le type d’accès mémoire (RAM, caches…)
  • Les colonnes “Symbol” et “Shared Object” détaillent quelle partie du code a déclenché l’accès.
  • Les colonnes “Data Symbol” et “Data Object” l’addresse mémoire ciblée par l’accès.
  • La colonne “Snoop” indique la présence de snooping (interception de données émise sur un bus par un autre périphérique connecté).
  • La colonne “TLB access” indique si une recherche d’addresse physique a été nécessaire ou si la correspondance virtuelle->physique était en cache (TLB).
  • La colonne “Locked” indique si il s’agissait d’accès atomiques Read-Modify-Write. Plus coûteux, ils sont utilisés pour la synchronisation entre threads.
  • Je n’ai pas trouvé de documentation sur les dernières colonnes, mes hypothèses actuelles :
    • “Blocked” désigne peut-être les accès invalides qui déclenchent un appel au système d’exploitation (ex : défauts de page).
    • “Local INSTR Latency” désigne peut-être à quel point l’exécution de code a été ralentie par cet accès mémoire (dans les cas simples, le CPU peut simplement exécuter d’autres instructions en attendant que les données arrivent).

Dans le cas présent, vous noterez qu’on observe les 8 lectures sur le tas attendus pour un accès à 64 flottants 32-bit en vectorisation AVX (8 flottants par accès), mais aussi deux lectures de la pile du programme inattendus. Ceux-ci sont un effet secondaire des barrières d’optimisation utilisées pour empêcher le compilateur de supprimer tout ou partie des boucles de calcul.

L’inhomogénéité des échantillons pour les accès en écriture est plus surprenante, elle est sans doute liée à des optimisations internes du CPU :

  1. Deux écritures ciblant une même ligne de cache (bloc aligné de 64 octets sous x86) sont regroupés en une même transaction mémoire à partir du cache L2.
  2. Une série d’écritures ciblant des addresses mémoire consécutives est un motif d’accès classique, qui est traité de façon optimisée par de nombreux CPUs. Les premiers accès sont alors plus lents que les suivants, le temps que l’optimisation se mette en place.

Exercice : Le paramètre --ldlat=N de perf mem record, que j’ai ici forcé à 0, filtre les données pour ne conserver que les lectures mémoire ayant une latence supérieure ou égale à N cycles.

En utilisant cette option pour se restreindre à des échantillons intéressants, augmenter graduellement la taille de boucle interne (premier paramètre de scale.bin) tout en diminuant le nombre d’itérations externes (2e paramètre) pour maintenir le temps d’exécution à peut près constant, et observez le franchissement des “paliers de cache”.

Vous constaterez rapidement que l’utilisation de perf mem sur des programmes non triviaux nécessite la mise en place de logs permettant de suivre l’emplacement en mémoire des différentes allocations tas effectuées par le programme, avant de pouvoir interpréter correctement les addresses mémoire présentes dans la sortie de perf help report ! Sur des programmes bien optimisés qui font peu d’allocations, cela peut être fait en utilisant un allocateur mémoire spécial qui affiche la pile d’appel de l’appelant à chaque allocation.

Utilisation avancée

Les commandes perf mem sont en réalité un raccourci vers des commandes perf record et perf report un peu complexes.

A l’heure où ces lignes sont écrites, perf mem record --ldlat=<N> est équivalent à perf record -e cpu/mem-loads,ldlat=<N>/P -e cpu/mem-stores/P --data --weight et perf mem report est à peu près équivalent à perf report --mem-mode --sort=overhead,sample,local_weight,mem,sym,dso,symbol_daddr,dso_daddr,snoop,tlb,locked,blocked,local_ins_lat.

L’intérêt de connaître ces commandes brutes est que…

  • Côté record, cela vous permet d’acquérir d’autres mesures en même temps que le profil mémoire, sur la même exécution du programme.
  • Côté report, cela vous permet d’afficher des profils simplifiés. Par exemple de ne faire que catégoriser les accès mémoire, sans les décomposer pour chaque addresse de données, avec un --sort n’incluant pas la clé symbol_daddr. Ou bien d’utiliser d’autres clés de tri.

Exercice : Affichez un profil de scale.bin ayant pour seules clés de tri l’overhead, le nombre d’échantillons, le type d’accès mémoire, le symbole où l’accès mémoire est survenu et le binaire associé, et les propriétés snoop, tlb, locked et blocked.

Vous obtiendrez ainsi un résumé de l’activité mémoire du programme un peu plus détaillé que ce qu’on peut obtenir juste avec perf stat.

Autres options

Au vu de la parenté de perf mem avec perf record et perf report, il ne vous surprendra pas que les options de ces dernières commandes soient supportés par la commande perf mem associée.

Il y a tout de même quelques options intéressantes spécifiques à perf mem, notamment…

  • L’option --type, qui s’abbrévie en -t, permet de ne mesurer que des lectures (load) ou des écritures (store).
  • L’option --dump-raw-samples, qui s’abbrévie en -D, permet d’afficher les échantillons dans un format facile à traiter par une machine, à la manière de perf script.
  • Les options --all-user et --all-kernel, qui s’abbrévient respectivement en -U et -K, jouent un rôle équivalent aux options :u et :k dans les noms d’événement : elles permettent de se limiter à l’activité applicative ou à l’activité noyau.

Comme d’habitude, n’hésitez pas à consulter perf help mem pour plus de détails.

perf c2c

Dans le milieu du parallélisme, il est de notoriété publique que synchroniser des threads a un coût non négligeable, et il est également assez connu qu’un code parallèle qui utilise trop de synchronisation ne sera pas seulement aussi lent qu’un code séquentiel, il sera beaucoup plus lent.

Ce qui est moins connu, c’est que cette lenteur provient surtout de deux mécanismes matériels :

  1. L’utilisation d’opérations atomiques Read-Modify-Write dans les primitives de synchronisation, qui sont bien plus coûteuses que des instructions non-atomiques équivalentes. On a vu que ces opérations atomiques peuvent être étudiées avec perf mem.
  2. La cohérence de cache, qui impose qu’à chaque instant, on ait soit un seul coeur CPU qui a un accès exclusif à une donnée, soit plusieurs coeurs CPU qui y ont accès en lecture seule. Ce qui signifie que quand un thread accède à une donnée écrite par un autre thread, la ligne de cache doit être transférée d’un coeur CPU à un autre. Cette opération a un coût conséquent, d’autant plus élevé que les threads sont sur des coeurs CPUs physiquement éloignés.

La cohérence de cache opère, comme son nom le suggère, avec la granularité d’une ligne de cache, soit 64 octets alignés en mémoire sur les processeurs x86 actuels. Cela signifie que si au sein de ce même bloc de données il y a deux variables utilisées par deux threads différents, même si ces deux threads ne communiquent jamais par le biais de ces variable, on observera une perte importante de performances due au fait que la ligne de cache associée passe son temps à sauter d’un coeur CPU à l’autre à cause de la cohérence de cache.

On parle alors de false sharing, et perf c2c est un outil conçu pour aider à étudier ce phénomène.

Introduction

L’exemple pingpong.cpp met en évidence le phénomène de false sharing dans un cadre simplifié. Un groupe de N threads partagent un tableau de N octets et lisent/écrivent chacun à un emplacement dédié du tableau. C’est l’exemple type de ce qu’il ne faut pas faire, et en dépit de l’absence de synchronisation après le démarrage du programme l’effet de false sharing sera extrêmement présent dans cette configuration.

Mesurons donc ce phénomène avec perf c2c en faisant attention à l’organisation des données…

cd ~/tp-perf \
&& srun make -j$(nproc) \
&& srun --pty -n1 -c16 --hint=nomultithread \
   perf c2c record --all-user \
   ./pingpong.bin 16 2000000000

Sortie de pingpong.bin

Ici, on utilise l’option --all-user, qui s’abbrévie en -u, pour ignorer les conflits au niveau du noyau Linux qui ne nous intéressent pas ici.

Maintenant, affichons les résultats de perf c2c :

perf c2c report

Ecran principal de perf c2c

perf c2c commence par afficher une table des lignes de cache pour lesquels plusieurs accès par différents threads ont été détectés. Pour chaque ligne de cache, sont indiqués…

  • Son addresse
  • Le noeud NUMA sur lequel elle réside (sur ce système il n’y en a qu’un seul)
  • “PA cnt” indique le nombre de fois où l’addresse physique à laquelle un thread accède a changé entre un échantillon perf et le suivant. Ici, les threads passent leur temps à accéder à des offsets différents au sein de la ligne de cache, donc ce nombre est très élevé.
  • Des statistiques sur le nombre de conflits détectés au niveau du cache L3 ou de la RAM (HITM = “HIT in a Modified cache line”) :
    • Fraction des conflits associés à chaque ligne de cache
    • Nombre total de conflits
    • Décomposition en conflits locaux (au sein d’un même noeud NUMA) et distant (sur un autre noeud NUMA, plus cher).
  • Une distribution des loads et des stores analogue à celle qu’on peut obtenir avec perf mem.

Notez que même dans cette configuration extrême, peu de conflits sont détectés au niveau du cache L3, ce qui est plutôt surprenant. En revanche, il est tout à fait normal qu’aucun accès RAM ne soit détecté, puisque même en situation de false sharing, les données restent dans un cache CPU.

Grâce à l’instrumentation de ping_pong.bin, on trouve rapidement le tableau partagé entre les threads (adresse 0x1f15f00) dans cette table. On y voit qu’environ 5% des lectures échantillonnées par perf ont dû passer par le Fill Buffer (interface entre le cache L1 et le cache L2), et que quelques écritures (0.5%) ne vont pas non plus dans le cache L1, mais dans un niveau de cache inférieur.

Avec la touche d, on peut obtenir un affichage plus détaillé pour cette ligne de cache…

Vue détaillée de perf c2c

…où l’on a le détail des accès observés, triés par offset, région du code concernée et noeud NUMA :

  • Les statistiques d’accès sont données en pourcentage des données pour la ligne de cache.
  • Pour chaque ligne de cache (“CL” = Cache Line), on a le détail par offset, et ici on a une valeur correcte du nombre d’adresses physiques.
  • Puis on voit quel code a accédé aux données (offset brut dans le code source et à droite symbole, binaire et ligne de code source si disponible).
  • Au milieu, on a une section “cycles” qui indique le nombre de cycles CPU cumulés utilisés pour les accès échantillonnés par perf c2c. On observe que bien que les accès HITM soient fortement minoritaires dans les mesures, leur contribution est énorme par rapport à celle des lectures normales.
  • Ensuite on a des données brutes de nombre d’échantillons, pour vérifier la qualité du résultat.
  • Puis on a le nombre de coeurs CPU qui ont accédé à chaque offset. Il est curieux que pour un des offset on ait 2 CPUs ici. Je soupçonne que c’est soit que perf a échantillonné l’initialisation du tableau, soit qu’un thread a migré d’un coeur CPU à un autre.
  • Enfin, sur la colonne de droite, on aurait, sur un système NUMA, des information sur le noeud exécutant chaque thread. On peut, avec la touche n, basculer dans un mode où les numéros de CPUs concernés sont affichés.

Exercice : Variez le nombre de threads (premier paramètre de l’exemple pingpong.bin et observez comment cela affecte le temps d’exécution et les statistiques de false sharing mesurées par perf c2c. Vous aurez peut-être besoin de l’option --show-all décrite ci-dessous…

Autres options

Par défault, perf c2c report n’affiche que les lignes de caches pour lesquelles des accès au cache L3 de type “HITM” ont été détecté. La détection de tels accès est, on l’a vu, assez rare, donc il arrive fréquemment qu’aucun accès HITM ne soit détecté, et donc qu’aucune ligne de cache ne soit affichée. Vous pouvez désactiver ce filtrage avec l’option --show-all.


perf c2c record prend des options similaires à perf mem record, et perf c2c record --ldlat=<N> est équivalent à perf record --weight --data --phys-data --sample-cpu -e cpu/mem-loads,ldlat=<N>/P -e cpu/mem-stores/P. On peut également passer des options spécifiques à perf record après une paire de tirets --.

En revanche, contrairement à perf mem report, perf c2c report ne peut pas être imité/reconfiguré avec perf report.


Parmi les options intéressantes de perf c2c report, détaillées dans perf help c2c, mentionnons la possibilité d’avoir un rapport non interactif (--stdio) et celle d’afficher une pile d’appel pour les accès concurrents afin de mieux comprendre comment on en est arrivé à exécuter chaque code.

Autres commandes

J’ai exclu de ce TP un certain nombre de commandes de la suite perf, principalement parce qu’elles me semblaient moins utiles dans la vie de tous les jours ou parce qu’il était difficile pour X ou Y raison d’en enseigner le maniement sur srv-calcul-ambulant.

Dans cette annexe, nous allons cependant faire quand même un point rapide sur ces commandes, leur fonction, et la cause de leur non-inclusion dans le TP.

Gestion des symboles de déboguage

Nous l’avons vu au cours du TP, perf fait un usage assez intensif des symboles de déboguage. Ce qui pose quelques problèmes en pratique.

Tout d’abord, sous les distributions Linux actuelles, les symboles de déboguage des bibliothèques systèmes sont stockés dans des fichiers distincts des bibliothèques elle-mêmes. Cela permet aux personnes qui n’ont pas besoin de ces symboles de ne pas les installer dans un premier temps, quitte à revenir sur cette décision plus tard. Mais cela signifie aussi qu’un outil comme perf ou GDB qui utilise des symboles de déboguage doit, à chaque fois qu’il en a besoin, faire une série de requêtes dans un certain nombre de répertoires dans l’espoir de les trouver, ce qui peut devenir coûteux quand on s’y réfère souvent.

Pour éviter ces requêtes à répétition, perf garde en cache l’emplacement où il a trouvé les symboles de déboguage de tous les binaires ELF qu’il a analysés, indexés par le build-id, un identifiant interne partagé par le binaire ELF et ses informations de déboguage. Vous pouvez gérer ce cache au moyen de la commande perf buildid-cache.

Un problème connexe est la répétabilité des analyses effectués avec perf sur une machine différente de celle où des mesures ont été acquises. Cette répétabilité est déjà, à la base, restreinte par l’interopérabilité limitée qui existe entre différentes versions de perf. Mais quand les informations de déboguage entrent dans l’équation, le problème devient encore plus complexe, puisqu’il est difficile de répliquer de façon exacte les versions de binaires existant sur une machine A sur une autre machine B en utilisant uniquement le gestionnaire de paquets.

perf propose donc la commande perf archive, qui combine un fichier perf.data avec les différents symboles de déboguage dont il dépend dans une archive pour faciliter la répétition d’analyses de performance sur une machine distante.

Dans une logique similaire, nous pouvons aussi mentionner la commande perf kallsyms, qui permet de connaître l’emplacement en mémoire virtuelle où sont chargés les différents symboles du noyau, ce qui est utilisé en interne par perf report lors de l’analyse de l’utilisation CPU du noyau.

Manipulation de fichiers perf.data

Quelques outils permettent de manipuler les fichiers perf.data produits par perf record :

  • perf data a vocation a devenir un outil généraliste pour manipuler ces fichiers, mais à l’heure où sont écrites ces lignes il ne peut que les convertir au format Common Trace Format.
  • perf diff permet de calculer les différences entre les profils de deux fichiers perf.data. Sur le principe, ça pourrait être très intéressant dans une activité d’optimisation de performances. Mais pour l’instant, cet outil ne travaille qu’en terme de fonctions, (overhead “Self” de perf report) et pas de graphes d’appels (overhead “Children”). L’information qu’il affiche est donc généralement trop bas niveau pour être utilisable.
  • perf evlist permet de savoir quels événements ont été enregistrés dans un fichier.
  • perf inject permet de partir d’un fichier perf.data et en tirer un second fichier perf.data contenant quelques données supplémentaires : build-ids des fichiers profilés, événements artificiels indiquant pendant combien de temps un thread s’est assoupi… Pour l’instant, ses fonctionnalités sont très spécifiques à des cas d’utilisation bien particuliers.

Commandes incompatibles avec srv-calcul-ambulant

Les commandes perf suivantes sont intéressantes dans l’absolu, mais ne peuvent pas être présentées dans le cadre d’un TP sur srv-calcul-ambulant car elles sont incompatibles avec certaines des décisions de conception de ce serveur :

  • perf ftrace propose un mécanisme alternatif de suivi des événements système basé sur l’infrastructure ftrace. Cette commande est hardcodée pour n’être utilisable par root, et n’est donc pas utilisable par des utilisateurs non administrateurs du serveur.
  • perf lock permet d’analyser l’utilisation des mutex, mais utilise pour ça des tracepoints qui ne sont pas activés sur les noyaux fournis par la distribution openSUSE que nous utilisons.

Autres commandes semblant peu intéressantes

Ces commandes perf peuvent être utilisées sur srv-calcul-ambulant, mais leur utilité ne semblait pas énorme pour l’audience Reprises, c’est pourquoi elles ne font pas partie du TP actuel :

  • perf bench contient une série de microbenchmarks de diverses fonctionnalités du noyau Linux. Il me semble que d’autres logiciels comme Stress-NG font mieux ce travail.
  • perf config permet de régler certains paramètres de façon permanente pour ne pas avoir à passer les mêmes flags encore et encore. L’ensemble de paramètres configurables est toutefois quelque peu limité…
  • perf daemon permet de déléguer l’exécution de commandes perf record à un groupe de services en tâche de fond. L’intérêt par rapport à une simple utilisation interactive de perf record ne semble pas évident, sauf peut-être dans une optique d’administration système…
  • perf kmem permet de mesurer quelques statistiques d’activité de l’allocateur mémoire interne du noyau linux. L’information obtenue est limitée, et je n’ai à ce jour jamais rencontré un problème de performances où il jouait un rôle.
  • perf kvm permet d’obtenir des informations sur des machines virtuelles hébergées par une infrastructure KVM. Les membres du projet Reprises ont peu de chance d’avoir un jour un accès shell à l’hyperviseur d’une infrastructure KVM, donc il semble peu utile d’aborder cela.
  • perf sched permet d’obtenir des informations sur l’ordonnancement des tâches et notamment les latences d’ordonnancement, c’est à dire le délai entre le moment où une tâche est exécutable et le moment où elle commence vraiment à s’exécuter. Cette information n’est généralement pertinente que sur des systèmes temps réels ou hébergeant un nombre de tâches actives supérieur au nombre de coeurs CPU, deux configurations qui me semblent peu courantes pour l’audience Reprises.
  • perf timechart permet de mesurer l’activité CPU et E/S de l’ensemble du système et de la visualiser sous forme d’un énorme graphique SVG avec une ligne par coeur CPU et une ligne par processus système en vol. Mais le résultat est difficilement exploitable, car le SVG résultant est si riche en détails qu’il met à genoux la plupart des outils de visualisation.
  • perf version ne fait que dupliquer le résultat de la commande perf --version à laquelle vous aurez probablement déjà pensé…

Interfaces graphiques

Bien que perf fournisse en standard quelques interfaces graphiques, celles-ci sont… hautement oubliables, et l’on est généralement bien plus avisé d’utiliser les outils en ligne de commande.

Toutefois, ce fort accent sur la ligne de commande peut rebuter certains publics, et plus particulièrement des personnes qui ne sont pas informaticiennes de métier ou de vocation.

Dans cette annexe, nous allons donc interfaces graphiques pour utiliser perf et plus particulièrement visualiser les données produites.

Hotspot

Quand on fait une recherche sur internet à la recherche d’une interface graphique pour perf, l’un des premiers résultats sur lequel on tombe est Hotspot de KDAB.

Malheureusement, ce n’est pas le choix que je recommanderais en première intention, car il n’utilise pas le moteur de perf pour lire les fichiers perf.data mais une bibliothèque maison, appelée perfparser. Or, mon expérience est que la compatibilité de cette bibliothèque avec les fichiers produits par perf est très imparfaite, particulièrement dans le domaine des graphes d’appels, ce qui conduit à observer des profils corrompus là où perf report aurait produit de bons résultats.

La fonction de capture de données est également minimaliste et peu compatible avec un usage sur serveur (application Qt sans version ligne de commande), donc on gagnera à enregistrer les données avec une commande perf record manuelle et à n’utiliser Hotspot que pour la visualisation. Les auteurs fournissent des instructions pour cela sur leur README.

Si l’on oublie ces deux limites, Hotspot fournit bien les fonctionnalités essentielles d’une interface graphique pour visualiser des données issues de perf record :

  • Visualisation hiérarchique d’un graphe d’appels (flame graph).
  • Visualisation temporelle des instants où des échantillons ont été enregistrés (ce qui permet d’identifier facilement des moments où l’application n’utilise pas les CPUs : E/S, attente d’un mutex ou processus fils…) avec possibilité de “zoomer” facilement sur une fenêtre de temps.
  • Possibilité de parcourir facilement le graphe d’appels dans les deux sens (vers les appelants et vers les appelés), alors qu’avec perf report il faut plusieurs commandes.

En revanche, on n’aura pas accès à des fonctions plus avancées de perf report comme la visualisation d’assembleur annoté, l’affichage configurable (option --sort). Et le support des tracepoints est anecdotique. Ne vous attendez donc pas à remplacer perf report à 100%…

Firefox Profiler

Si l’on accepte le constat qu’il n’existe pas, à l’heure actuelle, de bonne interface graphique pour effectuer des mesures avec perf, seulement pour les visualiser, Firefox Profiler est intéressant.

Bien que cet outil soit à la base pensé pour le profilage de sites web, il est possible d’y importer des profils perf avec des résultats plutôt satisfaisants. Voici un exemple issu de l’expérience Belle 2.

Comme Hotspot, Firefox Profiler supporte la visualisation en flame graph, en ligne temporelle, et en graphe d’appels bidirectionnel. Et comme Hotspot, Firefox Profiler ne supporte pas les fonctionnalités plus avancées de perf report (assembleur annoté, tracepoints, …).

En revanche…

  • Le décodage des fichiers perf.data est effectué avec perf lui-même, donc la visualisation est rigoureusement identique à ce qu’aurait produit perf report.
  • La visualisation en ligne temporelle est plus ergonomique qu’avec Hotspot, il suffit notamment de passer la souris dessus pour visualiser un échantillon des piles d’appels à un instant T.
  • Les menus contextuels (par clic droit) sont très puissants, et permettent notamment de cacher facilement des étapes de la pile d’appels non pertinentes pour l’analyse effectuée afin de rendre la visualisation plus claire pour le non initié.
  • Et surtout, la possibilité de partager facilement son profil en ligne sur une instance hébergée par Mozilla est très précieuse dans les collaborations, particulièrement quand on doit échanger avec des utilisateurs d’autres systèmes d’exploitation. En effet, la plupart des autres interfaces graphiques pour visualiser des profils perf sont soit spécifiques à Linux, soit des applications web à installer sur son propre serveur avec toute la lourdeur que cela entraîne.

Autres applications web

Pour explorer quelques autres options, incluant SysProf du projet GNOME (que je ne détaille pas ici car il est vraiment trop basique en termes de fonctionnalité par rapport aux deux autres) et l’import de profils perf dans KCacheGrind, vous pouvez consulter la page suivante : https://www.markhansen.co.nz/profiler-uis/.

Installation de perf

Vous n’aurez pas besoin d’installer perf sur votre ordinateur pour ce TP, puisqu’il sera effectué sur le serveur de calcul ambulant où j’ai déjà tout installé pour vous.

Mais vous me remercierez d’avoir écrit cette annexe du TP quand vous essaierez d’installer perf sur vos machines plus tard, car il y a un certain nombre de points auxquels ils faut faire attention, et ceux ci ne sont documentés à aucun emplacement centralisé…

Prérequis

Version noyau

perf utilise les compteurs de performance du CPU (Performance Monitoring Counters ou PMCs en anglais). Cette fonctionnalité est définie au niveau de la microarchitecture CPU, donc elle peut changer d’une génération de CPU à l’autre, et le code de perf devra alors être adapté.

Comme la partie de perf qui communique avec les PMCs est localisée dans le noyau linux (module events), il en résulte que pour éviter tout problème de support matériel avec perf, vous devez utiliser un noyau Linux aussi récent que possible, et au moins aussi récent que votre CPU.

Vous pouvez vérifier votre version de noyau avec la commande uname

uname -r
5.14.21-150400.24.21-default

…votre modèle de CPU avec la commande lscpu

lscpu
Architecture:                    x86_64
CPU op-mode(s):                  32-bit, 64-bit
Byte Order:                      Little Endian
Address sizes:                   46 bits physical, 48 bits virtual
CPU(s):                          36
On-line CPU(s) list:             0-35
Thread(s) per core:              2
Core(s) per socket:              18
Socket(s):                       1
NUMA node(s):                    1
Vendor ID:                       GenuineIntel
CPU family:                      6
Model:                           85
Model name:                      Intel(R) Core(TM) i9-10980XE CPU @ 3.00GHz

   ... autres infos intéressantes dans l'absolu, mais pas ici ...

…et trouver les dates de sorties correspondantes sur :

Si votre noyau est un peu ancien par rapport à votre CPU, il est possible que votre distribution Linux vous donne accès à un noyau plus récent. Par exemple, les versions LTS de Ubuntu peuvent utiliser les noyaux des versions non-LTS via le mécanisme des Hardware Enablement Stacks (kernels -hwe). Consultez la documentation de votre distribution pour plus de détails.

Virtualisation et conteneurs

Dans l’ensemble, pour éviter les problèmes, il est fortement recommandé de privilégier quand c’est possible l’utilisation de perf sur des systèmes bare metal, sans virtualisation ni conteneurs. Néanmoins, avec un peu de travail, on peut faire entendre raison même à des systèmes virtualisés.

Les compteurs de performance CPU permettent de faire un suivi très fin de l’activité système, et ce suivi peut être malheureusement détourné à des fins d’espionnage. Pour cette raison, de nombreux hyperviseurs de machines virtuelles désactivent l’accès aux PMCs par défaut.

Si vous rencontrez des difficultés dans l’utilisation de perf sur un système virtualisé, il est possible que vous soyez face à ce problème. Dans ce cas, consultez la documentation de votre hyperviseur pour savoir comment autoriser la machine virtuelle à accéder aux PMCs.

L’utilisation de perf au sein de conteneurs qui s’exécutent nativement sous Linux ne devrait pas être sujette à des problèmes de ce type. En revanche…

  • La plupart des moteurs de conteneurs empêchent l’exécution de perf par défaut, il faut donner les bonnes permissions pour pouvoir utiliser perf.
  • La version de perf installée dans le conteneur doit être proche de la version du noyau du système hôte pour éviter des problèmes de compatibilité.
  • L’utilisation de conteneurs sous macOS et Windows implique l’utilisation d’une machine virtuelle, et est donc concernée par l’avertissement ci-dessus.

Installations

Packaging de perf

Le packaging du profileur perf varie malheureusement d’une distribution Linux à une autre. Sauf indication contraire, je vais vous donner ici les instructions pour la distribution openSUSE, qui seront à adapter pour votre distribution favorite.

Le minimum vital pour utiliser perf est bien sûr d’installer le paquet perf, qui porte ce nom sur la plupart des distributions utilisant des paquets RPM. Sous Debian et Ubuntu, il n’existe pas de paquet dédié, il faut à la place installer les paquets linux-tools-common et linux-tools-$(uname -r).

Je recommande très fortement l’installation de perf via le gestionnaire de paquet de la distribution, car il faut s’assurer que la version de perf installée reste compatible avec la version noyau installée, et l’installation via le gestionnaire de paquet offre cette garantie automatiquement et la préserve au fil des mises à jour système futures…

Symboles de déboguage

Applications

Pour pouvoir profiler du code dans des conditions optimales, vous aurez besoin des informations de déboguage et idéalement du code source des programmes que vous comptez analyser, ainsi que pour l’ensemble des autres programmes et bibliothèques qu’ils utilisent de façon transitive.

Sous openSUSE, pour chaque paquet xyz qu’on veut analyser, il faut installer les paquets xyz-debuginfo et xyz-debugsource associés. Sur des systèmes qui ont l’analyse de performances comme vocation première, tels que srv-calcul-ambulant, il peut être intéressant de prendre l’habitude d’installer les paquets -debuginfo et -debugsource de façon systématique.

Pour les binaires que vous compilez vous-même, il faut demander au compilateur de générer ces informations. Avec GCC et clang, l’option -g joue ce rôle. Dans le cas des projets compilés avec CMake, l’option -DCMAKE_BUILD_TYPE=RelWithDebInfo est votre amie.

Noyau Linux

Pour pouvoir profiler l’activité du noyau Linux, vous aurez également besoin des informations de déboguage et du code source du noyau. Sous openSUSE il s’agit des paquets kernel-default-debuginfo, kernel-default-debugsource et kernel-sources, en supposant que vous utilisiez le noyau par défaut de la distribution (default).

Désassembleur Intel xed

Pour afficher les instructions assembleur quand vous utilisez Intel PT, vous aurez besoin de l’outil xed de Intel. Celui-ci est rarement inclus dans les distributions Linux, il s’installe comme ceci :

git clone https://github.com/intelxed/mbuild.git mbuild \
&& git clone https://github.com/intelxed/xed \
&& cd xed \
&& ./mfile.py --share \
&& ./mfile.py examples \
&& sudo ./mfile.py --prefix=/usr/local install \
&& sudo ldconfig \
&& sudo cp obj/wkit/examples/obj/xed /usr/local/bin

Si tout s’est bien passé, à la fin vous aurez un exécutable xed fonctionnel :

xed | head -3

Intel XED

Premiers tests

perf fournit une batterie de tests automatisés que vous pouvez lancer avec la command perf test. Pour s’affranchir des problèmes de permissions dans un premier temps, lancez-la en tant que root :

sudo perf test
 1: vmlinux symtab matches kallsyms                       : Ok
 2: Detect openat syscall event                           : Ok
 3: Detect openat syscall event on all cpus               : FAILED!
 4: Read samples using the mmap interface                 : Ok
 5: Test data source output                               : Ok
 6: Parse event definition strings                        : Ok
 7: Simple expression parser                              : Ok
 8: PERF_RECORD_* events & perf_sample fields             : Ok
 9: Parse perf pmu format                                 : Ok
10: DSO data read                                         : Ok
11: DSO data cache                                        : Ok
12: DSO data reopen                                       : Ok
13: Roundtrip evsel->name                                 : Ok
14: Parse sched tracepoints fields                        : Ok
15: syscalls:sys_enter_openat event fields                : Ok

    ... beaucoup d'autres tests ...

Si un test échoue (“FAILED!”) ou ne s’exécute pas (“Skip”), vous pouvez l’exécuter en mode verbeux pour essayer de comprendre pourquoi :

sudo perf test -v 3
 3: Detect openat syscall event on all cpus               :
--- start ---
test child forked, pid 5998
registering plugin: /usr/lib64/traceevent/plugins/plugin_cfg80211.so
registering plugin: /usr/lib64/traceevent/plugins/plugin_function.so
registering plugin: /usr/lib64/traceevent/plugins/plugin_hrtimer.so
registering plugin: /usr/lib64/traceevent/plugins/plugin_jbd2.so
registering plugin: /usr/lib64/traceevent/plugins/plugin_kmem.so
registering plugin: /usr/lib64/traceevent/plugins/plugin_kvm.so
registering plugin: /usr/lib64/traceevent/plugins/plugin_mac80211.so
registering plugin: /usr/lib64/traceevent/plugins/plugin_sched_switch.so
registering plugin: /usr/lib64/traceevent/plugins/plugin_scsi.so
registering plugin: /usr/lib64/traceevent/plugins/plugin_xen.so
sched_setaffinity() failed on CPU 2: Invalid argument test child finished with -1
---- end ----
Detect openat syscall event on all cpus: FAILED!

Ici, le problème est lié au fait que le test perf essaie d’exécuter du code sur un coeur CPU du serveur ambulant auquel il n’a pas accès, ce qui ne sera pas bloquant en pratique.

Notez que certains des tests de perf utilisent des fonctionnalités franchement obscures (ex : exécution de programmes Windows via wine), et qu’il n’est pas nécessaire que tous les tests passent pour utiliser perf.

Contrôle d’accès

Comme mentionné précédemment, perf permet d’obtenir des informations extrêmement détaillés sur l’activité système, ce qui représente un risque de sécurité sur une machine avec plusieurs utilisateurs. Pour éviter tout accident, la plupart des distributions Linux limitent donc l’utilisation d’au moins une partie des fonctionnalités de perf au superutilisateur root par défaut.

Voici une liste (probablement non exhaustive) de verrous de sécurité qui sont installés par défaut sur les distributions Linux courantes, et de procédures pour les désactiver si nécessaire.

perf_event_paranoid

L’utilisation de base de perf est contrôlée par le réglage perf_event_paranoid du noyau. Ce réglage peut être modifié temporairement en utilisant la commande sysctl kernel.perf_event_paranoid=x, ou de façon permanente en écrivant kernel.perf_event_paranoid=x dans un fichier du répertoire /etc/sysctl.d.

Les valeurs possibles de ce réglage sont :

  • 3 : Les utilisateurs non privilégiés ne peuvent pas utiliser perf du tout (réglage par défaut sous Debian, Ubuntu et Android).
  • 2 : Les utilisateurs non privilégiés ne peuvent suivre que des processus individuels, et seulement durant l’exécution en mode utilisateur (pas de suivi de l’activité noyau déclenchée par le processus).
  • 1 : L’utilisation de perf est toujours restreinte au suivi de processus individuels, mais le suivi de l’activité noyau directement associée au processus (appels système synchrones) est possible.
  • 0 : Certaines formes de suivi de l’activité du système entier deviennent possible (ex : suivi des compteurs de performance CPU), mais le suivi à grain fin de l’activité système via les mécanismes de tracing du noyau demeure inaccessible aux utilisateurs non privilégiés.
  • -1 : Aucune restriction n’est appliquée au niveau de l’outil perf (même si des contrôles d’accès peuvent encore s’appliquer en aval de l’outil, on va le voir plus loin).

Les noyaux récents devraient fournir un contrôle d’accès à grain plus fin basé sur les capabilities, permettant par exemple de ne donner accès à perf qu’à un certain groupe d’utilisateurs. Mais mon dernier test sous Linux 5.14 n’était pas concluant, il fallait encore avoir perf_event_paranoid=-1 pour un accès complet à perf.

kptr_restrict

Pour interpréter un profil l’activité CPU du noyau, il est nécessaire de savoir à quelle partie du noyau on a affaire. perf mesure la position du pointeur d’instruction du noyau, mais pour traduire cette information bas niveau en information symbolique de haut niveau (nom de fonction), il a besoin de connaître la position en mémoire des différents symboles du noyau.

Cette information est malheureusement également très utile pour mener une attaque contre le noyau Linux, et elle n’est donc accessible qu’à root par défaut. Pour la rendre accessible à tous les utilisateurs, il faut mettre à 0 le réglage kptr_restrict du noyau.

Là encore, on peut le faire de façon temporaire avec la commande sysctl kernel.kptr_restrict=0, ou de façon permanente en écrivant kernel.kptr_restrict=0 dans un fichier du répertoire /etc/sysctl.d.

Et là encore, il devrait y avoir moyen de gérer ce contrôle d’accès avec la capability CAP_SYSLOG, mais cela ne semble pas fonctionner sous Linux 5.14.

Tracing

Le suivi à grain fin de l’activité noyau (tracing) est une fonctionalité du noyau Linux qui est particulièrement puissante pour l’analyse de performances, mais aussi particulièrement dangereuse sur un système multi-utilisateurs :

  • Elle donne trivialement accès à l’ensemble des données passées en paramètres ou reçues en résultat d’appels système par les applications, ce qui peut inclure toutes sortes d’informations secrètes : mots de passe, clés SSH…
  • Il est relativement facile de charger fortement le système en l’utilisant, soit en créant une récursion infinie (suivi d’un évènement qui survient lorsqu’on suit un événement), soit en suivant de façon exhaustive un évènement extrêmement fréquent (ex : basculement entre deux tâches).

Cette fonctionnalité dispose donc de ses propres mécanismes de sécurité en sus de la protection perf_event_paranoid susmentionnée. Si vous souhaitez la rendre accessible à un groupe d’utilisateurs, disons le groupe perf, vous devez…

  • Remonter les pseudo-systèmes de fichier /sys/kernel/debug/tracing et /sys/kernel/tracing avec des permissions 770 (u+rwx g+rwx).
  • Remonter le pseudo-système de fichier /sys/kernel/debug avec les permissions 750 (u+rwx g+rx) pour permettre la navigation jusqu’à /sys/kernel/debug/tracing.
  • Transférer la propriété de /sys/kernel/debug et /sys/kernel/tracing au groupe perf.
  • Ajuster les permissions des fichiers de /sys/kernel/debug/tracing pour faire en sorte que tout ce qui est inscriptible par l’utilisateur l’est par le groupe.

Voici un service systemd permettant d’effectuer ces modifications à chaque démarrage :

[Unit]
Description=Let perf group use ftrace, kprobes and uprobes
RequiresMountsFor=/sys/kernel/tracing
RequiresMountsFor=/sys/kernel/debug/tracing
Before=multi-user.target

[Install]
WantedBy=multi-user.target

[Service]
Type=oneshot
RemainAfterExit=yes
Restart=no
ExecStart=bash -c 'mount -o remount,mode=770 /sys/kernel/tracing && mount -o remount,mode=750 /sys/kernel/debug && mount -o remount,mode=770 /sys/kernel/debug/tracing && chown -R root:perf /sys/kernel/tracing /sys/kernel/debug/tracing && find /sys/kernel/debug/tracing/ -perm -u=w -exec chmod g+w \'{}\' \\+'
ExecStop=bash -c 'mount -o remount,mode=700 /sys/kernel/tracing && mount -o remount,mode=700 /sys/kernel/debug/tracing && mount -o remount,mode=700 /sys/kernel/debug && chown -R root:root /sys/kernel/tracing /sys/kernel/debug'

Si vous utilisez perf probe, sachez que cette commande peut remettre certains des fichiers virtuels sous le contrôle de root:root et que vous serez amené à refaire des chown pour rétablir les permissions voulues, comme dans les exemples “administrateur” de la section perf probe de ce TP.

Limites

Il pourrait être remarquablement facile de bloquer un système en étant trop gourmand dans l’utilisation de perf, par exemple en lui demandant de suivre chaque cycle CPU, chaque changement de contexte (basculement d’une tâche à une autre) ou chaque interruption matérielle.

Pour éviter ça, perf s’impose de rester sous certaines limites d’utilisation des ressources système, quitte à jeter des données de mesure si ces limites sont dépassées.

Comme le réglage perf_event_paranoid, ces limites sont exposées sous forme de paramètres sysctl, et peuvent être configurées ponctuellement via la commande sysctl ou de façon permanente via le répertoire /etc/sysctl.d.

Les limites que l’on a le plus de chance d’être amené à ajuster sont…

  • perf_event_max_sample_rate, qui contrôle la fréquence d’échantillonnage maximale utilisable avec perf record.
  • perf_cpu_time_max_percent, qui contrôle le pourcentage maximal du temps CPU qui peut être employé à traiter des mesures perf.

Bien entendu, je ne peux que suggérer de les modifier avec prudence et parcimonie.

Introduction au BPF

Les initiales BPF désignaient historiquement un composant obscur de Linux appelé le Berkeley Packet Filter, qui permet d’optimiser la performance des outils de capture de paquets réseau comme tcpdump en éliminant les paquets réseau non désirés directement au niveau du noyau, sans devoir les transmettre à l’application de capture de paquets.

Les programmes BPF subissent des vérifications qui visent à assurer qu’ils ne peuvent pas causer trop de dommages à leur “hôte”. En particulier, ils ne peuvent pas crasher, ni entrer dans une boucle infinie, ni modifier des structures de données ou du code qui ne leur appartiennent pas.

Depuis le début des années 2010, les capacités de cet outil ont été énormément étendues, jusqu’à en faire un outil générique pour injecter du code dans un programme (noyau Linux, applications…). En plus des paquets réseau, il est aujourd’hui possible d’attacher un programme BPF à toutes les sources d’événements abordées dans ce TP (PMCs, tracepoints, kprobes, uprobes et USDT), pour effectuer toutes sortes d’actions en réponse à l’événement.

Les possibilités des programmes BPF ont aussi été étendues, ainsi ils peuvent désormais manipuler des tables de hachage, les partager avec des applications utilisateur, mesurer des piles d’appel, et appeler un petit nombre de fonctions du noyau Linux.

Au niveau des limitations, le nombre d’APIs noyau accessibles est très réduit, les ressources utilisables sont limitées (tant en mémoire qu’en temps CPU), et on ne peut pas faire de boucle dont le compilateur ne peut pas prouver qu’elle est bornée. De plus, en raison d’un grand nombre de failles de sécurité découvertes dernièrement dans l’infrastructure sous-jacente, la plupart des distributions Linux restreignent l’utilisation de BPF à l’administrateur. C’est pour cette dernière raison que je ne peux pas vous en faire manipuler facilement en TP.

En termes d’utilisations concrètes…

  • Au niveau de perf, on peut injecter des programmes BPF au niveau d’un événement, dont la fonction est de filtrer les événements enregistrés par perf au niveau noyau, et d’y ajouter éventuellement des données supplémentaires (ce qui est par exemple très utile quand on veut extraires des informations textuelles dans une kprobe/uprobe).
  • En-dehors du monde perf, il est possible de calculer certains types de profils en histogramme directement dans le noyau, sans passer par le processus de copie des données en userspace de perf et donc d’extraire des informations de façon systématique (à chaque occurence d’un événement) avec un coût plus faible que ce qui est possible avec perf.

Ces possibilités sont explorées en détail dans le livre de Brendan Gregg BPF Performance Tools.

Références