Profiler une image native GraalVM avec perf
L’outil GraalVM native-image permet de générer un exécutable natif (ou image native) depuis votre application Java.
Cet exécutable natif va démarrer très rapidement et avoir une empreinte mémoire beaucoup plus faible qu’une application Java traditionnelle; au prix de performances en pic réduites et d’un temps de création de ce package natif assez élevé. Plus d’informations sur les exécutables natifs ici.
Un exécutable natif contient une JVM minimaliste appelée SubstratVM, celle-ci a quelques limitations :
- Support partiel de la reflection
- Support partiel des proxies dynamiques
- Support partiel du chargement dynamique des classes
- Pas de JNI
- Pas de JVMTI
Pas de support de JVMTI signifie pas de support des agents Java, de JMX, des profilers Java, des debuggers Java, de Java Flight Recorder et Java Mission Control, ainsi que de tous les outils livrés avec le JDK (jps, jstack, jmap).
Pour tous les besoins couverts par ces outils, il faut donc utiliser une solution intégrée à l’application (par exemple, remplacer les métriques JMX par des métriques Prometheus), ou des outils standards fournit par votre système d’exploitation.
Pour profiler l’exécution d’une application, l’OS Linux a un outil très puissant : perf.
L’outil perf a de nombreuses fonctionnalités, il peut accéder à toutes les métriques de l’OS et du CPU (performance counters, d’où son nom : perf) et profiler l’application de plein de manières différentes.
Utiliser perf pour profiler le CPU
L’outil perf va utiliser les symboles intégrés au binaire de votre application pour faire le lien entre un pointeur mémoire et la méthode Java correspondante (ou l’appel système).
Par défaut, ces symboles ne sont pas intégrés aux exécutables natifs, il faut donc demander à l’outil native-image de les y laisser via les options H:-DeleteLocalSymbols -H:+PreserveFramePointer
.
Si vous voulez testez ces étapes, vous pouvez utiliser l’application getting-started de Quarkus. Quarkus a un support facilité de l’outil native-image, il suffit d’ajouter à l’application.properties
de votre application la propriété quarkus.native.additional-build-args=-H:-DeleteLocalSymbols,-H:+PreserveFramePointer
et celui-ci va automatiquement ajouter ces options à la ligne de commande de l’outil native-image.
Après avoir généré votre exécutable natif, vous pouvez le lancer, puis récupérer son PID; nous utiliserons celui-ci dans la ligne de commande de l’outil perf.
Une fois votre application lancée, et idéalement sous charge (vous pouvez utiliser un outil tel que wrk pour générer de la charge), vous pouvez la profiler via la commande perf suivante : perf record -F 99 -p PID --call-graph dwarf sleep 10
.
- record : demande à perf de commencer à profiler l’application.
- -F 99 : profile à 99 Hertz, ce qui veut dire 99 samples par seconde.
- -p PID : demande à perf de profiler ce PID en particulier (celui de votre application).
- –call-graph dwarf : indique à perf d’utiliser les symboles intégrés à votre application (symbole ELF).
- sleep 10 : comme perf profile un PID et pas une commande, il faut lui passer une commande à exécuter. Quand cette commande sera terminée, perf arrêtera le profilage de votre application. En utilisant
sleep 10
comme commande, on va donc profiler l’application pendant 10 secondes.
Quand la commande est terminée, perf aura généré un fichier de données qui contiendra le profil de votre application (profil CPU ici, car on ne lui a pas précisé quel événement il devait profiler) : perf.data
.
Vous pouvez utiliser la commande suivante pour visualiser ce profil dans la console : perf report --stdio
, vous aurez alors un résultat proche de celui-ci :
# Children Self Command Shared Object Symbol > # ........ ........ ............... ................................... ..............................................................................................................................> # 13.47% 0.00% tloop-thread-19 libpthread-2.31.so [.] start_thread | ---start_thread IsolateEnterStub_PosixJavaThreads_pthreadStartRoutine_e1f4a8c0039f8337338252cd8734f63a79b5e3df_06195ea7c1ac11d884862c6f069b026336aa4f8c JavaThreads_threadStartRoutine_241bd8ce6d5858d439c83fac40308278d1b55d23 Thread_run_857ee078f8137062fcf27275732adf5c4870652a FastThreadLocalRunnable_run_0329ad2c5210a091812879bcecd155c58e561e60 ThreadExecutorMap$2_run_66c8943ee6536a10df07f979fb6cd278adcf96bc SingleThreadEventExecutor$4_run_1b47df7867e302a2fb7f28d7657a73e92f89d91f | |--12.64%--NioEventLoop_run_be89580b4d16514bef6e948913d2ed21c5e4f679 | | | |--5.14%--NioEventLoop_processSelectedKeys_9a76c58d657b781ee037bbb65f41f01d2eb54e7c | | NioEventLoop_processSelectedKeysOptimized_c36ca161e53573665bc03cb5392e91c123bcd359 | | NioEventLoop_processSelectedKey_3a0d92ce472db6c251df4485227a85acb9d3a1ca | | AbstractNioByteChannel$NioByteUnsafe_read_45358e803c643a6380776021e488e79d981b159d
Et ce sur des milliers de lignes … pas facile à analyser hein ?
Pour analyser facilement un profil généré par perf, on peut utiliser l’outil FlameGraph, accessible ici : https://github.com/brendangregg/FlameGraph
Un FlameGraph est une manière de visualiser le profil d’une application permettant de détecter instantanément le chemin de code le plus fréquent. Il va afficher, en abscisse, la population (généralement la méthode) dont la taille est proportionnelle aux nombres de samples du profil, et en ordonnée la profondeur dans la stack. Plus d’informations sur les FlameGraphs ici.
On peut noter un petit soucis dans les données de profil, la colonne Command au lieu de contenir la commande passée, contient le nom du thread (tronqué qui plus est). C’est un bug dans l’outil native-image, pour le contourner, nous allons utiliser sed pour modifier les données de profil avant des les utiliser dans l’outil FlameGraph. La valeur de la colonne Command se retrouve à la base du FlameGraph, elle doit normalement être unique pour que l’aggrégation des stacks se fassent.
La première étape est d’utiliser perf script
pour extraire les données du profil dans un format textuel, puis d’utiliser sed
pour corriger les problèmes de nom de commande pour pouvoir ensuite générer un FlameGraph.
perf script > out.perf sed -i -E "s/cutor-thread-[0-9]*/executor-thread/" out.perf sed -i -E "s/ntloop-thread-[0-9]*/eventloop-thread/" out.perf sed -i -E "s/tloop-thread-[0-9]*/eventloop-thread/" out.perf ~/FlameGraph/stackcollapse-perf.pl out.perf | ~FlameGraph/flamegraph.pl > perf.svg
Voici un exemple de FlameGraph généré :
Ce que j’apprécie le plus avec les FlameGraphs c’est que l’on peut zoomer dessus en cliquant sur une frame :
Utiliser perf pour profiler la mémoire
Pour profiler la mémoire, nous allons utiliser la même technique avec une commande légèrement modifiée.
Il y a plusieurs manières de profiler la mémoire avec perf, on peut demander à perf d’enregistrer des événements OS liés à la mémoire, profiler une des méthodes système qui alloue la mémoire, ou utiliser pef mem
. C’est cette dernière solution que l’on va utiliser.
Pour cela, il faut démarrer votre application via l’outil perf : perf mem record --call-graph dwarf -F 99 ./getting-started-1.0-SNAPSHOT-runner
.
Quand l’application s’arrête, perf va enregistrer sur le disque les données de profil qui pourront alors être utilisées de la même manière que celles du profil CPU (via perf report, perf script et l’outil FlameGraph).
Pour aller plus loin
Un talk que j’ai donné sur le sujet (en anglais), démarre à la minute 44 : https://www.youtube.com/watch?v=TXnJ9eyoEhw.
Des conseils pour utiliser perf avec plein de recettes toutes faites : http://www.brendangregg.com/perf.html.
Un article décrivant en détail ce qu’est un FlameGraph : https://queue.acm.org/detail.cfm?id=2927301.