Journal Prise de poids et perte de perf

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes : aucune
81
21
juil.
2023

Sommaire

Bonjour 'nal,

Il m'est arrivé un truc de ouf, une énigme de dev comme je n'en avais pas vu depuis longtemps : par un malheureux concours de circonstances mon application en C++ a pris 5% de temps d'exécution en plus suite à la suppression d'une seule ligne, un #include <utils.hpp>.

Accroche-toi, il s'avère que la cause de l'augmentation du temps d'exécution était uniquement liée à l'augmentation de la taille du binaire. Mais pourquoi diable sa taille a-t-elle augmentée en supprimant cette inclusion ? Attrape un mug de ton breuvage favori, je vais te détailler l'enquête. C'est parti.

Effet de bord par inclusions

Devant un comportement inattendu, sans cause apparente, il faut commencer par restreindre les possibilités en isolant le changement qui a causé la régression. Pour ça il n'y a pas de mystère, j'ai pris la liste des 42 fichiers que j'avais modifiés et je les ai restaurés en dichotomie jusqu'à faire disparaître le problème. Le fichier coupable était, disons, bar.cpp.

Dans ce fichier j'avais juste enlevé l'inclusion de utils.hpp, tu sais, le genre de fichier qui inclus plein de trucs pour maximiser le couplage. En la remettant le problème disparaissait. Comme il n'y avait pas de rapport direct entre utils.hpp et bar.cpp j'ai supprimé un niveau en remplaçant cette inclusion par une autre, indirectement faite dans utils.hpp. Nommons cet autre entête base.hpp. Les perfs sont toujours bonnes avec ce changement. Bien, ça fait sens.

Ne voyant rien dans base.hpp qui pouvait expliquer la différence de perfs, et comme il inclut plein de fichiers, j'ai décidé de prendre un raccourci en regardant le code traité par le compilateur après l'étape du préprocesseur :

  1. Récupérer la commande qui compile bar.cpp.o en lançant make VERBOSE=1 ou ninja -v. Ça ressemble à g++ -I… -f… -D… -o …/bar.cpp.o -c …/bar.cpp
  2. Retirer le fichier de sortie et ajouter l'option -E pour afficher la sortie du préprocesseur : g++ -I… -f… -D… -E -c …/bar.cpp.

J'ai gardé la sortie du préprocesseur avec et sans l'inclusion de base.hpp et j'ai fait un diff entre les deux. Il y avait pas mal de différences. En cherchant une piste dans le source, je regarde dans bar.cpp et je vois qu'il utilise std::abs. Je me concentre là dessus et je vois que la version avec base.hpp en a plusieurs définitions. L'une est celle de cstdlib (i.e. la fonction abs de la lib C) :

inline long
abs(long __i) { return __builtin_labs(__i); }

L'autre est celle de cmath (i.e. la fonction abs de la STL) :

template<typename _Tp>
inline constexpr
typename __gnu_cxx::__enable_if<__is_integer<_Tp>::__value,
                                    double>::__type
abs(_Tp __x)
{ return __builtin_fabs(__x); }

Et oui, la version de std::abs fournie avec GCC 4.8 utilise fabs même pour les paramètres entiers, on a donc une double conversion : int -> float puis float -> int. Ce n'est que si cstdlib est inclus que la version long est disponible et préférée par rapport au template.

Et là il faut que je précise que ce n'est pas la faute de GCC mais bien d'une ambiguïté du standard C++ qui a été résolue à l'époque de GCC 7 (cf. LWG 2192 et LWG 2294). Pas de chance pour moi, je suis coincé avec GCC 4.8 :'(

Confirmer la cause

Pour confirmer que le problème vient bien de là je remplace l'inclusion de base.hpp par cstdlib. Les perfs sont toujours correctes \o/

J'ai isolé le problème à un entête, il ne reste plus qu'à confirmer que c'est bien lié à l'appel à std::abs. Je commence par faire un diff des instructions du programme avec et sans l'inclusion de cstdlib. Désassembler un programme sous une forme « diffable » n'est pas immédiat, notamment à cause des adresses des sauts et appels de fonctions qui vont être différentes au moindre ajout d'instruction. Pour contourner cela j'ai bricolé avec la commande suivante

objdump --demangle \
        --disassemble \
        --no-show-raw-insn \
        -M intel \
        my_program \
    | sed 's/ \+#.\+$//' \
    | sed 's/0x[a-f0-9]\+/HEX/g' \
    | sed 's/\(\(call\|j..\) \+\)[0-9a-f]\+/\1HEX/' \
    | sed 's/^\([ ]\+\)[0-9a-f]\+:/\1  HEX:/'

Quelques explications sur la commande. L'outil objdump avec ces paramètres va afficher les instructions en assembleur qui correspondent au programme. Ensuite j'envoie la sortie dans sed pour remplacer ce qui gêne le diff par des informations génériques (ici en plusieurs commandes pour la lisibilité du journal) :

  • les commentaires de fin de ligne sont supprimés ;
  • les valeurs hexadécimales (e.g. pour les accès mémoire) remplacées par HEX ;
  • les adresses pour les appels de fonctions et les sauts remplacées par HEX ;
  • les adresses des instructions remplacées par HEX.

Transformées ainsi les sorties peuvent être envoyées dans diff ce qui montre clairement que la seule différence impactante entre les versions avec et sans cstdlib est l'ajout de conversions int <-> float au niveau des appels à std::abs :

cvtsi2sd  xmm0, edi    ; entier vers float
andps     xmm0, XMMWORD PTR .L_2il0floatpacket.0[rip]
cvttsd2si eax, xmm0    ; float vers entier

Effet de bord de l'effet de bord

Tout cela est bien joli mais ça n'explique toujours pas la perte de performance. Vois-tu, ces fonctions qui font des conversions int <-> float ne sont pas exécutées par le benchmark. Pour celui-ci on utilise une version alternative implémentée avec des intrinsèques AVX2. Ce n'est donc pas le changement de l'implémentation de abs qui fait la différence.

Par contre, en regardant le diff d'assembleur, je vois bien qu'il y a un paquet d'instructions supplémentaires quand cstdlib est absent ; j'en compte à peu près 100 000, pour environ 500 ko sur la taille du binaire. Maintenant, si tout cela concerne du code non-exécuté, notre meilleure piste est que ça gêne la récupération des instructions pour l'exécution du reste du programme. J'essaye de confirmer cela en utilisant l'outil perf pour mesurer les cache-misses pour les instructions (évènement L1-icache-load-misses), et bingo : 11 à 20% de cache-misses supplémentaires quand cstdlib est absent. A-ha ! Enfin !

Chose étonnante, j'observe cette perte quand mon binaire passe de 12,1 Mo à 12,6 Mo (tailles obtenues en activant LTO), mais pas de perte en passant de 13,2 à 13,7 Mo (tailles obtenues en désactivant LTO). Cette énigme restera de côté pour l'instant.

Bilan

J'ai voulu nettoyer mes inclusions et, parce que j'utilise un vieux compilateur, ça a changé le code généré. Le changement a causé une augmentation de la taille de mon binaire, taille qui a dépassé un seuil causant une forte augmentation des cache-misses et donc une perte de performances.

Pfiou, ce n'était pas grand chose mais ça m'a pris des semaines. Tout ça a cause de la suppression d'une inclusion d'entête. Comme quoi il vaut mieux être trop inclusif… Hein, quoi ? Ah, on me dit que je mélange tout. Tant pis, c'est fini.

P.S. : Sur le sujet de la taille des binaires il y a une série de billets sympa par Sandor Dargo, par exemple ici : https://www.sandordargo.com/blog/2023/07/19/binary-sizes-and-compiler-flags

  • # Astuces

    Posté par  (site web personnel, Mastodon) . Évalué à 4.

    Ensuite j'envoie la sortie dans sed pour remplacer ce qui gêne le diff par des informations génériques

    Et ben c'est très malin ça! Merci, je vais le noter dans ma boîte à outils!

    Chose étonnante, j'observe cette perte quand mon binaire passe de 12,1 Mo à 12,6 Mo (tailles obtenues en activant LTO), mais pas de perte en passant de 13,2 à 13,7 Mo (tailles obtenues en désactivant LTO). Cette énigme restera de côté pour l'instant.

    Il me semble que la LTO à l'époque de GCC 4.8, c'était pas extraordinaire. Un état des lieux d'époque annonçant les améliorations prévues pour les versions suivantes.

    Est-ce que tu utilises function-sections, data-sections et gc-sections pour découper les fichiers .o en sections séparées et supprimer les sections inutiles? Pour éliminer le code mort, cela fonctionnera peut-être mieux. Et on peut ajouter print-gc-sections pour que le linker dise ce qu'il a éliminé du binaire final.

    • [^] # Re: Astuces

      Posté par  (site web personnel) . Évalué à 4.

      Il me semble que la LTO à l'époque de GCC 4.8, c'était pas extraordinaire.

      Je ne suis pas allé dans les détails mais en réalité j'utilise ICC 18, qui s'appuie sur le GCC du système pour la lib standard, en l'occurrence GCC 4.8. Par conséquent c'est ICC qui s'occupe du LTO. J'ai testé dans un autre cadre avec des compilateurs plus récents (GCC 11, Clang 7, ICX 22, ICC 22) et ils donnent tous de meilleurs résultats, sauf ICC 22 !

      Est-ce que tu utilises function-sections, data-sections et gc-sections pour découper les fichiers .o en sections séparées et supprimer les sections inutiles?

      Pas encore ! J'aimerais bien tester mais avec un compilateur récent. Tant que la boîte ne fait pas la transition c'est pas très motivant d'aller fouiller dans les options des compilateurs.

      • [^] # Re: Astuces

        Posté par  . Évalué à 3.

        As-tu essayé strip -x ?

        • [^] # Re: Astuces

          Posté par  (site web personnel) . Évalué à 2.

          Oui, ça ne change pas la taille de mon binaire. J'imagine que CMake a déjà émis une commande similaire. Par contre ça fait une recherche surprenante quand on oublie le tiret.

  • # Kékidi

    Posté par  . Évalué à 10.

    Moi j’ai pas compris grand chose à ce que tu as fait mais j’ai compris la logique de l’enquête et de l’argumentaire… ce qui veut dire que c’est bien écrit. Merci.

  • # Les caches d'instructions sont parfois capricieux

    Posté par  . Évalué à 9. Dernière modification le 23 juillet 2023 à 12:15.

    Chose étonnante, j'observe cette perte quand mon binaire passe de 12,1 Mo à 12,6 Mo (tailles obtenues en activant LTO), mais pas de perte en passant de 13,2 à 13,7 Mo (tailles obtenues en désactivant LTO). Cette énigme restera de côté pour l'instant.

    Pour comprendre cela il faut peut être se pencher sur les détails techniques des caches d'instructions.

    Considérons par exemple le processeur Intel Core i7-12700F.

    https://www.cpu-world.com/CPUs/Core_i7/Intel-Core%20i7%20i7-12700F.html

    Cette page nous indique que dans le "CPU core complex #1 - Performance cores", les 8 cores disposent de 8 caches L1 de taille 32 KB pour les instructions. Je suppose que chaque core a son propre cache. De plus le petit bouton triangulaire sur la droite indique aussi que "The level 1 instruction cache is an 8-way set associative cache with a line size of 64 bytes".

    J'en déduis donc que chaque cache d'instruction de 32KB gère 32KB/64B = 512 lignes de caches organisée en 64 groupes (=512/8) de 8.

    Maintenant, considérons un programme donc la partie critique du code fait 30KB. Il n'a donc besoin que de 480 lignes de caches (=30KB/64B) pour s'exécuter à pleine vitesse . A priori, cela doit tenir et chacun des 64 groupes devra traiter 480/64 = 7.5 lignes donc moins de 8. Malheureusement, ce n'est qu'une moyenne.

    Si tout se passe bien, certains groupes auront 7 lignes et d'autres 8 lignes.

    Par contre, avec de la malchance, on peut se retrouver avec des groupes nécessitants 9 ou même 10 lignes alors que d'autres seront à seulement 5 ou 6. Le hit ratio va alors s'effondrer dans les groupes traitants plus de 8 lignes.

    Il y a une quinzaine d'année, je me rappelle avoir été confronté à un problème de ce genre dans un code Fortran. Après l'avoir optimisé au petits-oignons, je décide de changer l'ordre de 2 fonctions dans un fichier parce que .. c'était plus joli comme cela. L'ordre des appels de fonctions ne changeant pas, j'ai été très surpris de constater une dégradation notable de la vitesse d'exécution. J'ai vérifié au niveau des binaires et la seule différence était l'ordre relatif des 2 fonctions en mémoire. Tout le reste était absolument identique au bit près. Les données de profiling ont confirmé qu'en intervertissant les 2 fonctions, j'avais modifié la distribution du code dans les lignes de caches. J'ai restauré l'ordre initial pour récupérer mes perfs et atteindre mon objectif.

    En pratique, il n'y a pas grand chose à faire quand ce genre de chose arrive. Ce n'est pas vraiment contrôlable à moins de tout programmer en assembleur pour un modèle spécifique de processeur.

    
    
  • # Quelques pistes

    Posté par  (site web personnel) . Évalué à 4.

    Très sympathique, cette enquête. Et une prose agréable, merci pour le partage ! Quelques, pistes, qui ne te seront peut-être pas accessibles vu les versions du compilo utilisées :

    • comme mentionné dans un autre commentaire, --gc-sections est un drapeau de l'éditeur de lien qui permet de dégager les symboles non référencés et non exportés, à combiner avec -ffunction-sections.

    • à défaut, on peut utiliser un orderfile qui force un order entres les différents symboles, assurant une meilleur localité du code. On peut le générer par instrumentation, avec dtrace, où (avec un clang "trunk") depuis les informations de profiling

    • en post-link, on peut réordonner les sections en utilisant les informations de profilage, p.e. avec Bolt.

  • # Le problème est il identique sur un autre CPU ?

    Posté par  . Évalué à 3.

    Parce que bon, les cache miss, pipeline, prédiction de branchement peuvent différer… la version du firmware dans un CPU intel peut aussi avoir de l’importance.

    Face à ce genre de problème, il n’y a pas grand chose à faire hélas.

  • # Conflits de cache pour causes très variées

    Posté par  . Évalué à 4.

    11 à 20% de cache-misses supplémentaires quand cstdlib est absent

    J'ai déjà vu ce genre de choses même à l'exécution plutôt qu'à la compilation. La présence ou non de variables d'environnement (non utilisées par le programme) modifiait les perfs de manière significative. Après enquête, la taille des données des variables d'environnement modifiait la place initiale de la pile, ce qui modifiait les conflits de cache de l'application. Ça avait été long à comprendre également.

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.