Julien Jorge's Personal Website

Prise de poids et perte de perf

Sun Jul 23, 2023

Ce post a été publié sur LinuxFr.org. Vous pouvez le lire là bas, ainsi que les commentaires associés.

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(int). 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 billet) :

  • 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