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.