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.