Du code Java/Kotlin/Clojure "natif" grâce à GraalVM

Posted by Nicolas Kosinski on 2019-08-13 Translations: en

Audience présumée : développeurs.euses intéressés.ées par l'écosystème Java et plus particulièrement sur l'amélioration des performances et la génération d'exécutables.

Plan :

  1. Introduction
  2. Outils utilisés
  3. Exécutable optimisé pour une application "WordCount" Java
  4. Exécutable optimisé pour une application Kotlin
  5. Exécutable moins optimisé pour une application Clojure
  6. Conclusion

Introduction

Suite à mes premiers essais infructueux l'an dernier (lire mon article précédent Du Clojure "natif" grâce à GraalVM), voici un compte rendu plus positif de mes expérimentations avec les versions release de GraalVM sorties à partir de mai 2019 (cf. les release notes), en utilisant des applications en ligne de commandes implémentée en Java, Kotlin et Clojure.

Outillage

Nous utiliserons les outils suivants :

  • GraalVM Community Edition ("a High-performance polyglot VM") et plus particulièrement la fonctionnalité Native Image via la commande native-image pour générer des exécutables à partir de bytecode JVM.
  • SDKMAN! ("The Software Development Kit Manager") pour installer / utiliser des versions différentes du Java Development Kit / Java Runtime Environment
  • time ("run programs and summarize system resource usage") pour mesurer le temps d'exécution
  • valgrind ("tool for memory debugging, memory leak detection, and profiling") pour évaluer l'empreinte mémoire des processus

Exécutable optimisé pour une application "WordCount" Java

Notre "hello world" est un programme Java de 10 lignes qui compte le nombre de mots d'un fichier de texte : wordcount-with-java-stream.

Générons un JAR exécutable via Maven et OpenJDK, cela prend 2 secondes sur ma machine :

$ sdk use java 8.0.222.hs-adpt
Using java version 8.0.222.hs-adpt in this shell.
$ time ./mvnw clean --quiet compile
./mvnw clean --quiet compile  6.24s user 0.31s system 323% cpu 2.022 total

Note : le temps d'exécution indiqué par la commande time se trouve à la fin de la dernière ligne, en secondes : 2.022 total pour 2,022 secondes.

Puis générons l'exécutable via GraalVM native-image, cela prend 42 secondes sur ma machine :

$ time $HOME/.sdkman/candidates/java/19.1.1-grl/bin/native-image \
     --enable-https \
     --no-fallback \
     --no-server \
     -cp target/classes org.nicokosi.WordCount \
     wordcount-with-java-stream
$HOME/.sdkman/candidates/java/19.1.1-grl/bin/native-image --enable-https   -c  236,70s user 2,75s system 566% cpu 42,285 total

Comparons les temps d'exécution pour un petit fichier d'entrée.

$ alias wordcount_java=" $HOME/.sdkman/candidates/java/8.0.222.hs-adpt/bin/java -cp target/classes org.nicokosi.WordCount"
$ time wordcount_java /etc/hosts
File /etc/hosts contains 26 words
/home/nkosinski/.sdkman/candidates/java/8.0.222.hs-adpt/bin/java -cp     0,16s user 0,02s system 152% cpu 0,118 total
$ time ./wordcount-with-java-stream /etc/hosts
File /etc/hosts contains 26 words
./wordcount-with-java-stream /etc/hosts  0,00s user 0,01s system 92% cpu 0,007 total

Puis comparons l'empreinte mémoire :

$ JAVA_HOME=$HOME/.sdkman/candidates/java/8.0.222.hs-adpt \
  valgrind java -cp target/classes org.nicokosi.WordCount /etc/hosts

==23352== HEAP SUMMARY:
==23352==     in use at exit: 34,892,297 bytes in 6,155 blocks
==23352==   total heap usage: 14,555 allocs, 8,400 frees, 49,960,719 bytes allocated  **

Note : la mémoire totale allouée est à la fin de la dernière ligne ; 49,960,719 bytes allocated signifie qu'environ 50 mégaoctets ont été alloués.

$ valgrind ./wordcount-with-java-stream /etc/hosts

==23753== HEAP SUMMARY:
==23753==     in use at exit: 10,468 bytes in 3 blocks
==23753==   total heap usage: 8 allocs, 5 frees, 12,436 bytes allocated**

Pour résumer, au prix d'un temps de compilation plus long (42 secondes au lieu de 2 secondes), GraalVM :

  • accélère l'exécution "courte" : 7 millisecondes au lieu de 118 millisecondes ;
  • réduit l'empreinte mémoire de notre application : 12 kilooctets au lieu de 50 mégaoctets.

Exécutable optimisé pour une application Kotlin

Faisons la même chose pour une application Kotlin un peu plus complexe, pullpitoK (200 lignes de codes avec des librairies tierces) qui consomme les API GitHub pour afficher des statistiques sur les pull requests GitHub.

La différence de temps de construction étant similaire au paragraphe précédent, concentrons-nous sur la comparaison du temps de démarrage pour une exécution rapide (affichage de l'aide souvent appelée "usage message") :

$ export PATH=$HOME/.sdkman/candidates/java/8.0.222.hs-adpt/bin:$PATH
$ time (java -jar ./build/libs/pullpitoK-all.jar | head -1)
Usage: pullpitoK <repository> <token>
( java -jar ./build/libs/pullpitoK-all.jar | head -1; )  0.08s user 0.02s system 108% cpu 0.093 total
$ alias pullpitoK="PULLPITOK_LIBSUNEC=$HOME/.sdkman/candidates/java/19.1.1-grl/jre/lib ./pullpitoK"
$ time (pullpitoK --help | head -1)
Usage: pullpitoK <repository> <token>
( PULLPITOK_LIBSUNEC=/Users/nicolas/.sdkman/candidates/java/19.1.1-grl/jre/li)  0.00s user 0.00s system 88% cpu 0.009 total

Soit 9 millisecondes avec la version native contre 93 millisecondes pour la version JVM.

Comparons maintenant l'empreinte mémoire :

$ valgrind java -jar ./build/libs/pullpitoK-all.jar
...
Usage: pullpitoK <repository> <token>
...
==26273== HEAP SUMMARY:
==26273==     in use at exit: 32,181,758 bytes in 2,134 blocks
==26273==   total heap usage: 5,725 allocs, 3,591 frees, 33,187,784 bytes allocated
...
$ valgrind pullpitoK | head -1
...
Usage: pullpitoK <repository> <token>
...
==27690== HEAP SUMMARY:
==27690==     in use at exit: 228 bytes in 1 blocks
==27690==   total heap usage: 6 allocs, 5 frees, 2,196 bytes allocated
...

Soit 2 kilooctets avec la version native contre 33 mégaoctets pour la version JVM.

Exécutable moins optimisé pour une application Clojure

Dans mon billet Du Clojure "natif" grâce à GraalVM, je me suis heurté à deux problèmes :

  • GraalVM était encore expérimental (release candidates) à l'époque
  • l'outil Native Image possède des limitations qui concernent notamment le chargement de classes dynamiques, l'utilisation de la réflexion (API java.lang.reflect) etc.

Essayons de refaire l'essai avec une version release de GraalVM pour l'application hubstats (200 lignes de codes, utilisation de librairies tierces pour appeler les API HTTP GitHub).

$ time $HOME/.sdkman/candidates/java/19.1.1-grl/bin/native-image \
   --enable-https \
   --no-fallback \
   --no-server \
   -jar target/hubstats-0.1.0-SNAPSHOT-standalone.jar \
   hubstats

La compilation native échoue. Voici un extrait du message d'erreur :

Error: Unsupported features in 4 methods
Detailed message:
Error: Unsupported type java.lang.invoke.MemberName is reachable: All methods from java.lang.invoke should have been replaced during image building.
To diagnose the issue, you can add the option --report-unsupported-elements-at-runtime. The unsupported element is then reported at run time when it is accessed the first time.
...

Nous pourrions corriger ça en adaptant le code source. Par facilité, essayons le mode fallback ("solution de repli") de Native Image qui permet de contourner les limitations en embarquant une JVM classique :

$ # Construction de l'exécutable avec GraalVM
time $HOME/.sdkman/candidates/java/19.1.1-grl/bin/native-image \
   --enable-https \
   --force-fallback \
   --no-server \
   -jar target/hubstats-0.1.0-SNAPSHOT-standalone.jar \
   hubstats
...
[hubstats:31661]      [total]:  14,663.95 ms
Warning: Image 'hubstats' is a fallback image that requires a JDK for execution (use --no-fallback to suppress fallback image generation).
$HOME/.sdkman/candidates/java/19.1.1-grl/bin/native-image --enable-https       78,73s user 1,31s system 536% cpu 14,926 total

Etant donné que le mode fallback est utilisé, les temps de démarrage sont maintenant similaires :

$ export PATH=$HOME/.sdkman/candidates/java/8.0.222.hs-adpt/bin:$PATH
$ time (java -jar target/hubstats-0.1.0-SNAPSHOT-standalone.jar | head -1)
Display statistics for GitHub pull requests.
( java -jar target/hubstats-0.1.0-SNAPSHOT-standalone.jar | head -1; )  3,36s user 0,10s system 262% cpu 1,318 total
$ time (./hubstats | head -1)

Display statistics for GitHub pull requests.
( ./hubstats | head -1; )  2,86s user 0,14s system 236% cpu 1,272 total

mais l'empreinte mémoire est énormément réduite :

$ export PATH=$HOME/.sdkman/candidates/java/8.0.222.hs-adpt/bin:$PATH
$ valgrind java -jar target/hubstats-0.1.0-SNAPSHOT-standalone.jar
...
Display statistics for GitHub pull requests.
...
==2690== HEAP SUMMARY:
==2690==     in use at exit: 38,656,326 bytes in 34,800 blocks
==2690==   total heap usage: 170,569 allocs, 135,769 frees, 406,386,571 bytes allocated
...
$ valgrind pullpitoK
...
Usage: pullpitoK <repository> <token>
...
==5747== HEAP SUMMARY:
==5747==     in use at exit: 228 bytes in 1 blocks
==5747==   total heap usage: 6 allocs, 5 frees, 2,196 bytes allocated
...

Conclusion

Au travers de ces trois petites applications utilisant des langages différents (Java, Kotlin et Clojure), nous avons pu vérifier les bénéfices des exécutables "images natives GraalVM" :

  • avoir un exécutable compact déployable sans Java Virtual Machine
  • une consommation mémoire réduite
  • un démarrage rapide (parfois !)

De plus, on pressent l'intérêt qu'aura GraalVM pour moderniser Java, en particulier pour un usage en cloud-computing et pour les microservices. Se référencer aux frameworks tels Quarkus et Micronaut.

A tester ultérieurement :

  • la gestion de la mémoire par le ramasse-miettes (garbage collector)

  • la différence entre les versions Community Edition et Enterprise Edition.

PS : merci à mes collègues de Vidal, notamment à Viviane, Marc et Jean-Christophe pour les échanges intéressants sur GraalVM et à Stéphane pour la relecture de cet article.