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
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
…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 :
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…
- Charger un 1er paquet de flottants, calculer le 2e compteur de boucle, spéculer que la boucle va continuer pour une 2e itération.
- 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.
- 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..
- …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
.