Enquête sur les techniques de profilage C ++
Dans cette réponse, j'utiliserai plusieurs outils différents pour analyser quelques programmes de test très simples, afin de comparer concrètement le fonctionnement de ces outils.
Le programme de test suivant est très simple et fait ce qui suit:
main
appels fast
et maybe_slow
3 fois, l'un des maybe_slow
appels étant lent
L'appel lent de maybe_slow
est 10 fois plus long et domine l'exécution si nous considérons les appels à la fonction enfant common
. Idéalement, l'outil de profilage pourra nous orienter vers l'appel lent spécifique.
à la fois fast
et maybe_slow
appel common
, qui représente la majeure partie de l'exécution du programme
L'interface du programme est:
./main.out [n [seed]]
et le programme fait des O(n^2)
boucles au total. seed
est juste d'obtenir une sortie différente sans affecter l'exécution.
principal c
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
uint64_t __attribute__ ((noinline)) common(uint64_t n, uint64_t seed) {
for (uint64_t i = 0; i < n; ++i) {
seed = (seed * seed) - (3 * seed) + 1;
}
return seed;
}
uint64_t __attribute__ ((noinline)) fast(uint64_t n, uint64_t seed) {
uint64_t max = (n / 10) + 1;
for (uint64_t i = 0; i < max; ++i) {
seed = common(n, (seed * seed) - (3 * seed) + 1);
}
return seed;
}
uint64_t __attribute__ ((noinline)) maybe_slow(uint64_t n, uint64_t seed, int is_slow) {
uint64_t max = n;
if (is_slow) {
max *= 10;
}
for (uint64_t i = 0; i < max; ++i) {
seed = common(n, (seed * seed) - (3 * seed) + 1);
}
return seed;
}
int main(int argc, char **argv) {
uint64_t n, seed;
if (argc > 1) {
n = strtoll(argv[1], NULL, 0);
} else {
n = 1;
}
if (argc > 2) {
seed = strtoll(argv[2], NULL, 0);
} else {
seed = 0;
}
seed += maybe_slow(n, seed, 0);
seed += fast(n, seed);
seed += maybe_slow(n, seed, 1);
seed += fast(n, seed);
seed += maybe_slow(n, seed, 0);
seed += fast(n, seed);
printf("%" PRIX64 "\n", seed);
return EXIT_SUCCESS;
}
gprof
gprof nécessite de recompiler le logiciel avec l'instrumentation, et il utilise également une approche d'échantillonnage avec cette instrumentation. Il établit donc un équilibre entre la précision (l'échantillonnage n'est pas toujours entièrement précis et peut sauter des fonctions) et le ralentissement de l'exécution (l'instrumentation et l'échantillonnage sont des techniques relativement rapides qui ne ralentissent pas beaucoup l'exécution).
gprof est intégré à GCC / binutils, donc tout ce que nous avons à faire est de compiler avec l' -pg
option pour activer gprof. Nous exécutons ensuite le programme normalement avec un paramètre CLI de taille qui produit une exécution d'une durée raisonnable de quelques secondes ( 10000
):
gcc -pg -ggdb3 -O3 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
time ./main.out 10000
Pour des raisons pédagogiques, nous effectuerons également une course sans optimisations activées. Notez que cela est inutile dans la pratique, car vous ne vous souciez normalement que d'optimiser les performances du programme optimisé:
gcc -pg -ggdb3 -O0 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
./main.out 10000
Tout d'abord, time
nous indique que le temps d'exécution avec et sans -pg
était le même, ce qui est super: pas de ralentissement! J'ai cependant vu des comptes rendus de ralentissements 2x - 3x sur des logiciels complexes, par exemple comme indiqué dans ce ticket .
Parce que nous avons compilé avec -pg
, l'exécution du programme produit un gmon.out
fichier contenant les données de profilage.
Nous pouvons observer ce fichier graphiquement avec gprof2dot
comme demandé à: Est-il possible d'obtenir une représentation graphique des résultats de gprof?
sudo apt install graphviz
python3 -m pip install --user gprof2dot
gprof main.out > main.gprof
gprof2dot < main.gprof | dot -Tsvg -o output.svg
Ici, l' gprof
outil lit les gmon.out
informations de trace et génère un rapport lisible par l'homme main.gprof
, qui gprof2dot
lit ensuite pour générer un graphique.
La source de gprof2dot est à: https://github.com/jrfonseca/gprof2dot
Nous observons ce qui suit pour la -O0
course:
et pour la -O3
course:
La -O0
sortie est assez explicite. Par exemple, cela montre que les 3 maybe_slow
appels et leurs appels enfants occupent 97,56% du temps d'exécution total, bien que l'exécution de maybe_slow
lui - même sans enfants représente 0,00% du temps d'exécution total, c'est-à-dire que presque tout le temps passé dans cette fonction a été consacré à les appels des enfants.
TODO: pourquoi est-il main
absent de la -O3
sortie, même si je peux le voir sur un bt
dans GDB? Fonction manquante de la sortie GProf Je pense que c'est parce que gprof est également basé sur l'échantillonnage en plus de son instrumentation compilée, et qu'il -O3
main
est tout simplement trop rapide et n'a obtenu aucun échantillon.
J'ai choisi la sortie SVG au lieu de PNG car le SVG est consultable avec Ctrl + F et la taille du fichier peut être environ 10 fois plus petite. De plus, la largeur et la hauteur de l'image générée peuvent être énormes avec des dizaines de milliers de pixels pour les logiciels complexes, et GNOME eog
3.28.1 bogue dans ce cas pour les PNG, tandis que les SVG s'ouvrent automatiquement par mon navigateur. gimp 2.8 a bien fonctionné, voir aussi:
mais même dans ce cas, vous ferez beaucoup glisser l'image pour trouver ce que vous voulez, voir par exemple cette image à partir d'un "vrai" exemple de logiciel tiré de ce ticket :
Pouvez-vous trouver facilement la pile d'appels la plus critique avec toutes ces petites lignes de spaghetti non triées qui se superposent? Il y a peut-être de meilleures dot
options, j'en suis sûr, mais je ne veux pas y aller maintenant. Ce dont nous avons vraiment besoin, c'est d'une visionneuse dédiée appropriée, mais je n'en ai pas encore trouvé:
Vous pouvez cependant utiliser la carte de couleurs pour atténuer un peu ces problèmes. Par exemple, sur la grande image précédente, j'ai finalement réussi à trouver le chemin critique sur la gauche lorsque j'ai fait la brillante déduction que le vert vient après le rouge, suivi enfin par le bleu de plus en plus sombre.
Alternativement, nous pouvons également observer la sortie texte de l' gprof
outil binutils intégré que nous avons précédemment enregistré à:
cat main.gprof
Par défaut, cela produit une sortie extrêmement détaillée qui explique la signification des données de sortie. Comme je ne peux pas expliquer mieux que ça, je vous laisse le lire vous-même.
Une fois que vous avez compris le format de sortie des données, vous pouvez réduire la verbosité pour afficher uniquement les données sans le didacticiel avec l' -b
option:
gprof -b main.out
Dans notre exemple, les résultats étaient pour -O0
:
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls s/call s/call name
100.35 3.67 3.67 123003 0.00 0.00 common
0.00 3.67 0.00 3 0.00 0.03 fast
0.00 3.67 0.00 3 0.00 1.19 maybe_slow
Call graph
granularity: each sample hit covers 2 byte(s) for 0.27% of 3.67 seconds
index % time self children called name
0.09 0.00 3003/123003 fast [4]
3.58 0.00 120000/123003 maybe_slow [3]
[1] 100.0 3.67 0.00 123003 common [1]
-----------------------------------------------
<spontaneous>
[2] 100.0 0.00 3.67 main [2]
0.00 3.58 3/3 maybe_slow [3]
0.00 0.09 3/3 fast [4]
-----------------------------------------------
0.00 3.58 3/3 main [2]
[3] 97.6 0.00 3.58 3 maybe_slow [3]
3.58 0.00 120000/123003 common [1]
-----------------------------------------------
0.00 0.09 3/3 main [2]
[4] 2.4 0.00 0.09 3 fast [4]
0.09 0.00 3003/123003 common [1]
-----------------------------------------------
Index by function name
[1] common [4] fast [3] maybe_slow
et pour -O3
:
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls us/call us/call name
100.52 1.84 1.84 123003 14.96 14.96 common
Call graph
granularity: each sample hit covers 2 byte(s) for 0.54% of 1.84 seconds
index % time self children called name
0.04 0.00 3003/123003 fast [3]
1.79 0.00 120000/123003 maybe_slow [2]
[1] 100.0 1.84 0.00 123003 common [1]
-----------------------------------------------
<spontaneous>
[2] 97.6 0.00 1.79 maybe_slow [2]
1.79 0.00 120000/123003 common [1]
-----------------------------------------------
<spontaneous>
[3] 2.4 0.00 0.04 fast [3]
0.04 0.00 3003/123003 common [1]
-----------------------------------------------
Index by function name
[1] common
Comme un résumé très rapide pour chaque section, par exemple:
0.00 3.58 3/3 main [2]
[3] 97.6 0.00 3.58 3 maybe_slow [3]
3.58 0.00 120000/123003 common [1]
centre autour de la fonction qui est en retrait ( maybe_flow
). [3]
est l'ID de cette fonction. Au-dessus de la fonction, se trouvent ses appelants, et en dessous les callees.
Pour -O3
, voyez ici comme dans la sortie graphique cela maybe_slow
et fast
n'avez pas de parent connu, c'est ce que la documentation dit que cela <spontaneous>
signifie.
Je ne sais pas s'il existe une bonne façon de faire du profilage ligne par ligne avec gprof: `gprof` le temps passé dans des lignes de code particulières
valgrind callgrind
valgrind exécute le programme via la machine virtuelle valgrind. Cela rend le profilage très précis, mais il produit également un très grand ralentissement du programme. J'ai également mentionné kcachegrind précédemment à: Outils pour obtenir un graphe d'appel de fonction picturale de code
callgrind est l'outil de valgrind pour profiler le code et kcachegrind est un programme KDE qui peut visualiser la sortie du cachegrind.
Tout d'abord, nous devons supprimer l' -pg
indicateur pour revenir à la compilation normale, sinon l'exécution échoue avec Profiling timer expired
, et oui, c'est tellement courant que je l'ai fait et il y avait une question de débordement de pile pour cela.
Nous compilons et exécutons donc:
sudo apt install kcachegrind valgrind
gcc -ggdb3 -O3 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
time valgrind --tool=callgrind valgrind --dump-instr=yes \
--collect-jumps=yes ./main.out 10000
J'autorise --dump-instr=yes --collect-jumps=yes
cela, car cela permet également d'afficher des informations qui nous permettent d'afficher une ventilation des performances par chaîne de montage, à un coût supplémentaire relativement faible.
Au départ, time
nous dit que le programme a pris 29,5 secondes pour s'exécuter, nous avons donc eu un ralentissement d'environ 15x sur cet exemple. De toute évidence, ce ralentissement va être une sérieuse limitation pour les charges de travail plus importantes. Sur "l'exemple du logiciel du monde réel" mentionné ici , j'ai observé un ralentissement de 80x.
La course génère un fichier de données de profil nommé callgrind.out.<pid>
par exemple callgrind.out.8554
dans mon cas. Nous visualisons ce fichier avec:
kcachegrind callgrind.out.8554
qui montre une interface graphique qui contient des données similaires à la sortie textuelle gprof:
De plus, si nous allons sur l'onglet "Call Graph" en bas à droite, nous voyons un graphique d'appel que nous pouvons exporter en cliquant dessus avec le bouton droit pour obtenir l'image suivante avec des quantités déraisonnables de bordure blanche :-)
Je pense que fast
ne s'affiche pas sur ce graphique parce que kcachegrind doit avoir simplifié la visualisation car cet appel prend trop peu de temps, ce sera probablement le comportement que vous souhaitez sur un vrai programme. Le menu du clic droit a quelques paramètres pour contrôler quand abattre de tels nœuds, mais je n'ai pas pu le faire afficher un appel aussi court après une tentative rapide. Si je clique sur fast
dans la fenêtre de gauche, il montre un graphique d'appel avec fast
, donc cette pile a été réellement capturée. Personne n'avait encore trouvé un moyen d'afficher le graphe d'appel complet: Faire en sorte que callgrind affiche tous les appels de fonction dans le callgraph kcachegrind
TODO sur un logiciel C ++ complexe, je vois quelques entrées de type <cycle N>
, par exemple, <cycle 11>
là où je m'attendrais à des noms de fonction, qu'est-ce que cela signifie? J'ai remarqué qu'il y avait un bouton "Détection de cycle" pour l'activer ou le désactiver, mais qu'est-ce que cela signifie?
perf
de linux-tools
perf
semble utiliser exclusivement des mécanismes d'échantillonnage du noyau Linux. Cela le rend très simple à configurer, mais aussi pas entièrement précis.
sudo apt install linux-tools
time perf record -g ./main.out 10000
Cela a ajouté 0,2 seconde à l'exécution, nous sommes donc très bien dans le temps, mais je ne vois toujours pas beaucoup d'intérêt, après avoir développé le common
nœud avec la flèche droite du clavier:
Samples: 7K of event 'cycles:uppp', Event count (approx.): 6228527608
Children Self Command Shared Object Symbol
- 99.98% 99.88% main.out main.out [.] common
common
0.11% 0.11% main.out [kernel] [k] 0xffffffff8a6009e7
0.01% 0.01% main.out [kernel] [k] 0xffffffff8a600158
0.01% 0.00% main.out [unknown] [k] 0x0000000000000040
0.01% 0.00% main.out ld-2.27.so [.] _dl_sysdep_start
0.01% 0.00% main.out ld-2.27.so [.] dl_main
0.01% 0.00% main.out ld-2.27.so [.] mprotect
0.01% 0.00% main.out ld-2.27.so [.] _dl_map_object
0.01% 0.00% main.out ld-2.27.so [.] _xstat
0.00% 0.00% main.out ld-2.27.so [.] __GI___tunables_init
0.00% 0.00% main.out [unknown] [.] 0x2f3d4f4944555453
0.00% 0.00% main.out [unknown] [.] 0x00007fff3cfc57ac
0.00% 0.00% main.out ld-2.27.so [.] _start
Alors j'essaie de comparer le -O0
programme pour voir si cela montre quelque chose, et seulement maintenant, enfin, je vois un graphique d'appel:
Samples: 15K of event 'cycles:uppp', Event count (approx.): 12438962281
Children Self Command Shared Object Symbol
+ 99.99% 0.00% main.out [unknown] [.] 0x04be258d4c544155
+ 99.99% 0.00% main.out libc-2.27.so [.] __libc_start_main
- 99.99% 0.00% main.out main.out [.] main
- main
- 97.54% maybe_slow
common
- 2.45% fast
common
+ 99.96% 99.85% main.out main.out [.] common
+ 97.54% 0.03% main.out main.out [.] maybe_slow
+ 2.45% 0.00% main.out main.out [.] fast
0.11% 0.11% main.out [kernel] [k] 0xffffffff8a6009e7
0.00% 0.00% main.out [unknown] [k] 0x0000000000000040
0.00% 0.00% main.out ld-2.27.so [.] _dl_sysdep_start
0.00% 0.00% main.out ld-2.27.so [.] dl_main
0.00% 0.00% main.out ld-2.27.so [.] _dl_lookup_symbol_x
0.00% 0.00% main.out [kernel] [k] 0xffffffff8a600158
0.00% 0.00% main.out ld-2.27.so [.] mmap64
0.00% 0.00% main.out ld-2.27.so [.] _dl_map_object
0.00% 0.00% main.out ld-2.27.so [.] __GI___tunables_init
0.00% 0.00% main.out [unknown] [.] 0x552e53555f6e653d
0.00% 0.00% main.out [unknown] [.] 0x00007ffe1cf20fdb
0.00% 0.00% main.out ld-2.27.so [.] _start
TODO: que s'est-il passé lors de l' -O3
exécution? Est-ce simplement cela maybe_slow
et fast
était trop rapide et n'a pas obtenu d'échantillons? Cela fonctionne-t-il bien avec -O3
des programmes plus importants dont l'exécution est plus longue? Ai-je manqué une option CLI? J'ai découvert le -F
contrôle de la fréquence d'échantillonnage en Hertz, mais je l'ai augmentée au maximum autorisé par défaut de -F 39500
(pourrait être augmenté avec sudo
) et je ne vois toujours pas d'appels clairs.
Une chose intéressante à propos de perf
l'outil FlameGraph de Brendan Gregg qui affiche les horaires de la pile d'appels d'une manière très soignée qui vous permet de voir rapidement les gros appels. L'outil est disponible sur: https://github.com/brendangregg/FlameGraph et est également mentionné sur son tutoriel de perf sur: http://www.brendangregg.com/perf.html#FlameGraphs Quand j'ai couru perf
sans sudo
je l'ai ERROR: No stack counts found
fait pour maintenant je vais le faire avec sudo
:
git clone https://github.com/brendangregg/FlameGraph
sudo perf record -F 99 -g -o perf_with_stack.data ./main.out 10000
sudo perf script -i perf_with_stack.data | FlameGraph/stackcollapse-perf.pl | FlameGraph/flamegraph.pl > flamegraph.svg
mais dans un programme aussi simple, la sortie n'est pas très facile à comprendre, car nous ne pouvons pas facilement voir ni maybe_slow
ni fast
sur ce graphique:
Sur un exemple plus complexe, il devient clair ce que le graphique signifie:
TODO il y a un journal de [unknown]
fonctions dans cet exemple, pourquoi?
Une autre interface graphique performante qui pourrait en valoir la peine comprend:
Plugin Eclipse Trace Compass: https://www.eclipse.org/tracecompass/
Mais cela a l'inconvénient que vous devez d'abord convertir les données au format de trace commun, ce qui peut être fait avec perf data --to-ctf
, mais il doit être activé au moment de la construction / avoir perf
suffisamment de nouveau, ce qui n'est pas le cas pour la perf dans Ubuntu 18.04
https://github.com/KDAB/hotspot
L'inconvénient est qu'il ne semble pas y avoir de paquet Ubuntu, et sa construction nécessite Qt 5.10 tandis qu'Ubuntu 18.04 est à Qt 5.9.
gperftools
Anciennement appelé "Google Performance Tools", source: https://github.com/gperftools/gperftools Sample based.
Installez d'abord gperftools avec:
sudo apt install google-perftools
Ensuite, nous pouvons activer le profileur de processeur gperftools de deux manières: au moment de l'exécution ou au moment de la construction.
Au moment de l'exécution, nous devons passer set the LD_PRELOAD
to point to libprofiler.so
, que vous pouvez trouver avec locate libprofiler.so
, par exemple sur mon système:
gcc -ggdb3 -O3 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libprofiler.so \
CPUPROFILE=prof.out ./main.out 10000
Alternativement, nous pouvons construire la bibliothèque au moment du lien, en passant LD_PRELOAD
au moment de l'exécution:
gcc -Wl,--no-as-needed,-lprofiler,--as-needed -ggdb3 -O3 -std=c99 -Wall -Wextra -pedantic -o main.out main.c
CPUPROFILE=prof.out ./main.out 10000
Voir aussi: gperftools - fichier de profil non vidé
La meilleure façon de voir ces données que j'ai trouvées jusqu'à présent est de faire en sorte que la sortie de pprof soit du même format que kcachegrind prend en entrée (oui, l'outil Valgrind-project-viewer-tool) et d'utiliser kcachegrind pour voir cela:
google-pprof --callgrind main.out prof.out > callgrind.out
kcachegrind callgrind.out
Après avoir exécuté l'une de ces méthodes, nous obtenons un prof.out
fichier de données de profil en sortie. Nous pouvons visualiser ce fichier graphiquement en SVG avec:
google-pprof --web main.out prof.out
qui donne comme un graphique d'appel familier comme les autres outils, mais avec l'unité maladroite de nombre d'échantillons plutôt que de secondes.
Alternativement, nous pouvons également obtenir des données textuelles avec:
google-pprof --text main.out prof.out
qui donne:
Using local file main.out.
Using local file prof.out.
Total: 187 samples
187 100.0% 100.0% 187 100.0% common
0 0.0% 100.0% 187 100.0% __libc_start_main
0 0.0% 100.0% 187 100.0% _start
0 0.0% 100.0% 4 2.1% fast
0 0.0% 100.0% 187 100.0% main
0 0.0% 100.0% 183 97.9% maybe_slow
Voir aussi: Comment utiliser les outils google perf
Testé dans Ubuntu 18.04, gprof2dot 2019.11.30, valgrind 3.13.0, perf 4.15.18, Linux kernel 4.15.0, FLameGraph 1a0dc6985aad06e76857cf2a354bd5ba0c9ce96b, gperftools 2.5-2.