Mensuel Shaarli

Tous les liens d'un mois sur une page.

March, 2023

Options de tuning pour la JRE

Contexte

Depuis quelques jours, la tâche m'incombe de comparer les performances de la JRE face à du code natif, le benchmark porte principalement sur Kotlin vs Rust.

Depuis Java 17, les choses ont é-nor-mé-ment évoluées au point où en termes de temps de calcul CPU ou de débits I/O purs, la JRE est plus rapide que C/C++ ou Rust sauf si l'on active les options de compilation de C/C++/Rust qui retirent la portabilité des exécutables entre Intel et AMD.

L'idée est donc d'obtenir, au moyen du paramétrage de la JRE, le meilleur compromis entre :

  • Les temps de calcul CPU
  • La charge moyenne CPU
  • Les débits I/O
  • Les temps de latence I/O
  • La quantité de mémoire consommée
  • Les temps de démarrage
  • Le poids total de l'application
  • La capacité à être supervisé/debuggé

Ce poste va donc regrouper les différentes options à passer soit aux compilateurs Kotlin et Java, soit à la JRE elle-même, ainsi que les pré-requis matériels qui en découlent en expliquant le pourquoi du comment (ça va être long mais le cours est gratuit alors profitez-en 😘).

Pré-requis

Depuis Java 11, la JRE est prévue pour fonctionner de manière optimale sur du multi-threads avec une certaine quantité de mémoire (au minimum 2 threads CPU et 2 Go de RAM). Aussitôt qu'elle s'exécute sur un seul thread CPU/vCPU ou moins de 2 Go de mémoire, alors elle considère être en environnement contraint et va activer des stratégies d'exécution plus lentes afin de fonctionner correctement (notamment SerialGC).

Sur une architecture x64/Aarch64, il faut donc une configuration matérielle minimale afin de ne pas tomber dans ce mode d'exécution aux performances limitées.

C'est pourquoi toutes les options ci-après porteront sur un matériel disposant :

  • De 4 threads CPU (2 physiques + 2 virtuels ou 4 physiques)
  • De 8 Go de RAM (car sur du 64 bits, en-dessous, c'est du gâchis)
  • D'un disque SSD ou NVMe (c'est-à-dire avec au moins 2 threads d'écriture/lecture simultanés)

Enfin, nous parlons ici de Java 17 et rien d'autre.

Quantité de mémoire réservée (non affectable à l'application)

Dans notre cas de figure, nous avons 8 Go à allouer de manière optimale. De ces 8 Go retranchons ce que nous ne pouvons pas prendre car pris par autre chose :

  1. La taille de l'OS + Services (System-D, SSH Pare-feu, fail2ban, borg backup, monitoring, etc) => 136 Mo
  2. La taille de la JRE qui est elle-même un programme => 24 Mo.
  3. Le taux d'espace réservé par la swappiness (chez moi 1%) => 80 Mo
  4. La marge d'erreur => 16 Mo

Soit un total de 256 Mo sur 8 Go qui ne seront jamais affectés à notre application.

Fonctionnement de Java 17

La mémoire est répartie en quatre zones :

  • La Stack ou pile d'appels (il y en a une par thread).
  • Le Young Space qui regroupe les espaces Eden et les Survivors où sont gérées les nouvelles instances.
  • Le Tenured Space, aka Old Space, qui regroupe toutes les instances ayant survécus au GC dans le Young Space.
  • Le Metaspace (qui reprend le rôle du PermGen) qui retrouve tout ce qui est statique, le byte-code compilé, les informations du compilateur JIT (Just In Time) et les méta-données de classes.

Depuis Java 8, on représente les nouvelles zones de la JRE par le diagramme suivant :
zones mémoires de la JRE

Dans notre cas de figure, ces zones vont se partager 8 Go - 256 Mo = 7 744 Mo. En sachant que la consommation réelle de la mémoire d'une JRE se calcule au moyen de l'addition suivante : [Taille de la pile d'appel x Nb Threads] + [Taille du Heap] + [Taille du Metaspace] + [Taille de la JRE (C-Heap)].

Choisir le bon Garbage Collector

Globalement trois choix s'imposent sur Java 17 :
G1 (Garbage-First)

  • S'active via l'option -XX:+UseG1GC.
  • À utiliser par défaut mais pour s'en servir de manière optimale il faut que votre application nécessite au moins 6 Go de mémoire OU qu'au moins 50% du heap contienne des objets encore en vie.

Shenandoah

  • S'active via l'option -XX:+UseShenandoahGC.
  • À utiliser si vous souhaitez minimiser le plus possible les temps de pause dus au GC (< 1 ms) OU que vous souhaitez des temps de pause semblables quelque soit la taille du heap entre 2 Go et 200 Go.

ZGC (expérimental)

  • S'active via l'option -XX:+UnlockExperimentalVMOptions -XX:+UseZGC.
  • À utiliser si votre application requiert plusieurs téraoctets de RAM (oui téra ou a minima quelques centaines de Go) OU que vous ayez besoin de régler le seuil de concurrence des cycles du GC parce que votre hardware dispose de trouze-mille threads matériels.

Je mets volontairement au rebus les GC trop lents ou dépréciés tels que Serial collector, Parallel collector, Concurrent Mark and Sweep et évidemment NoGC.

=> Dans notre cas de figure nous partirons sur Garbage-First / G1.

Paramétrage de la JRE

Toute la configuration de notre JRE se fera au moyen de la variable d'environnement JRE_OPTIONS qui sera passée en argument à la commande java. Par défaut nous exécuterons notre JRE en mode serveur.

Dimensionnement de la Stack

Nous avons un CPU à 4 threads. Dans un monde idéal, la JRE s'appuierait sur un pool de 4 threads CPU et chaque frameworks utiliserait un thread virtuel qui serait dépilé/traité par ceux du pool.

Mais nous ne vivons pas dans un monde idéal, nous allons donc limiter le nombre de threads de notre application à 16 threads physiques par threads CPU soit 4 x 16 = 64 threads au total.

Notre système ne disposant que de 8 Go de RAM, nous allons limiter la taille de chaque pile d'appels via l'option -Xss à 1 Mo soit 1 x 64 Mo = 64 Mo cumulés.

N.B : par défaut la JRE définit déjà une taille de pile d'appels à 1 Mo mais je préfère la forcer au cas où une mise à jour changerait cette valeur.

Cette limitation vient aussi du fait que je souhaite que le poids de la Stack ne dépasse pas 1% de la RAM. Il n'y a pas de raison particulière à cela mais afin de se représenter la chose, avec 1 Mo d'appels en cascade il est possible d'imbriquer dans le même algorithme :

  • ~2 000 méthodes dont chaque signature expose 6 paramètres + un type de retour.
  • ~3 900 méthodes dont chaque signature expose 3 paramètres sans type de retour.

Étant donné que je travail avec Jooby sur Netty il n'y aura pas de problème pour configurer le nombre de threads et la taille de la pile d'appel. Mais dans la pratique, il faut bien connaître ses frameworks ou tout profiler sinon.

Dimensionnement du Metaspace

Le Metaspace a pour comportement d'occuper tout l'espace restant afin d'éviter les OutOfMemory ; contrairement au PermGen que nous pouvions contraindre mais qui occupait l'espace du Heap.

Dans la pratique, le poids cumulé de tous les jars des frameworks que j'utilise pour un service RESTful est inférieur à 16 Mo mais une fois les jars décompressés ce sont ~40 Mo de fichiers répartis sur ~6 000 .class qui sont à charger.

En jouant avec les options -Xcomp, -Xbatch, XnoclassGC et -XCompileThreshold=1 j'ai forcé la JRE a compiler 100 % du bytecode en mémoire dans le Metaspace. Et en analysant le poids avec l'utilitaire JConsole, j'ai pu constater que 32 Mo étaient occupés.

Je suppose que la taille maximale du Metaspace devrait être égale au poids des .class + au poids des ressources chargées dans le classpath (même si a priori non). Comme mon système a beaucoup de RAM, je préfère ajouter une nouvelle fois 16 Mo de marge et ensuite profiler l'application pour déterminer si cette marge est toujours nécessaire.

Je vais donc passer la taille par défaut du Metaspace ainsi que la taille de ses partitions à 64 Mo. Je considère aussi que le Metaspace peut évoluer entre 5% et 95% de sa capacité avant d'être redimensionné à la hausse ou à la baisse.

Au final, la stratégie consistera à définir des options -Xmx et -Xms qui ne laisseront au Metaspace que les 64 Mo dont il aura besoin pour mon application. Évidemment, certains programmes codés en Spring Bouse réclameront facilement 100 Mo ou 200 Mo de Metaspace. Il faut donc mesurer l'espace occupé dans le pire cas de figure pour correctement paramétrer le reste.

Dimensionnement du Heap

Dans mon cas, ce sera facile. Il me suffit de reprendre la taille maximale disponible et de lui retrancher la taille du Metaspace, de la Thread Stack, de la JRE et de la marge d'erreur.

On obtient 7 744 - 64 - 64 - 24 - 16 = 7 576 Mo

Dimensionnement des zones du Heap

Comme vu plus haut, le Heap est décomposé en Young et Old spaces. Pour savoir quel espace allouer au Young Space (et donc à laisse au Old Space) il faut superviser l'application.

Dans mon cas de figure, comme j'ai appris à coder avec de petites instances immutables, imbriquées et jetables, j'ai besoin d'un gros Young Space par défaut, en sachant que celui-ci prendra toute la place qu'il peut dans le Heap s'il en a le besoin.

Sans profiling, je pars sur une répartition 3/4 Young et 1/4 Old, soit respectivement 5 682 Mo et 1 894 Mo sur les 7 576 Mo affectables. J'arrondis le 5 682 Mo à 5 680 Mo car ce sera plus pratique pour déclarer les ratios Eden vs Survivor.

Supervision de la JRE

La JRE produit elle-même des logs qui sont indispensables pour comprendre ses dysfonctionnement en production. L'idée est donc de définir une rolling policy ainsi qu'un fichier de sortie.

-XX:+UseGCLogFileRotation 
-XX:NumberOfGCLogFiles=< number of log files > 
-XX:GCLogFileSize=< file size >[ unit ]
-Xloggc:/path/to/gc.log

Paramètre de la ligne de commande

Au final nous obtenons ce script de démarrage

#!/usr/bin/env dash

## Mode server
JRE_OPTIONS="-server"                                                   

## Heap
* Nous avons min = max pour que la JRE s'affecte tout dès son démarrage et s'évite de perdre du temps aux resize
JRE_OPTIONS="${JRE_OPTIONS} -Xms7576m"                                  # Taille minimale du Heap
JRE_OPTIONS="${JRE_OPTIONS} -Xmx7576m"                                  # Taille maximal du Heap

## Young Space
JRE_OPTIONS="${JRE_OPTIONS} -XX:NewSize=5680m"                          # Taille par défaut du Young Space
JRE_OPTIONS="${JRE_OPTIONS} -XX:MaxNewSize=5680m"                       # Taille maximale du Young Space

## Thread Stack
JRE_OPTIONS="${JRE_OPTIONS} -Xss1m"                                     # Taille de la Stack d'un thread sur 16

## Taille du Metaspace
JRE_OPTIONS="${JRE_OPTIONS} -XX:MetaspaceSize=64m"                      # Seuil au-delà duquel le Metaspace grossi
JRE_OPTIONS="${JRE_OPTIONS} -XX:MaxMetaspaceSize=64m"                   # Taille maximale du Metaspace avant un OutOfMemory
JRE_OPTIONS="${JRE_OPTIONS} -XX:MinMetaspaceFreeRatio=5"                # Après un GC complet, pourcentage d'espace libre minimal du Metaspace avant son augmentation
JRE_OPTIONS="${JRE_OPTIONS} -XX:MaxMetaspaceFreeRatio=95"               # Après un GC complet, pourcentage d'espace libre maximal du Metaspace avant sa réduction

## Logs
JRE_OPTIONS="${JRE_OPTIONS} -Xloggc:/var/log/my-app/jre.log"            # Emplacement du fichier de log
JRE_OPTIONS="${JRE_OPTIONS} -XX:+UseGCLogFileRotation"                  # Mise en place d'une rotation des fichiers de log
JRE_OPTIONS="${JRE_OPTIONS} -XX:NumberOfGCLogFiles=10"                  # Nombre maximum de fichiers de logs
JRE_OPTIONS="${JRE_OPTIONS} -XX:GCLogFileSize=100m"                     # Taille maximale d'un fichier avant rotation

JRE_OPTIONS="${JRE_OPTIONS} -XX:+HeapDumpOnOutOfMemoryError"            # Dump de la mémoire en cas de OOM
JRE_OPTIONS="${JRE_OPTIONS} -XX:HeapDumpPath=/var/log/my-app/oom.log"   # Chemin vers le fichier
JRE_OPTIONS="${JRE_OPTIONS} -XX:OnOutOfMemoryError='shutdown -r '"      # Commande a exécuter en cas de OutOfMemory
JRE_OPTIONS="${JRE_OPTIONS} -XX:+UseGCOverheadLimit"                    # Limitation des temps GC avant qu'un OOM ne soit levé

## Optimisation du byte-code
JRE_OPTIONS="${JRE_OPTIONS} -XX:+UseStringDeduplication"                # Evite d'instancier plusieurs fois la même String
JRE_OPTIONS="${JRE_OPTIONS} -XX:+OptimizeStringConcat"                  # Utilise un StringBuffer pour concaténer des String quand c'est possible mais pas fait par le développeur

java ${JRE_OPTIONS} -cp ${CLASSPATH} ${mainClass}
Rust - Ne cibler qu'une seule architecture à la compilation

L'idée est de tirer 100% des optimisations possibles sur une architecture donnée pour ne cibler qu'elle mais ne plus être portable en contrepartie.

Il suffit de déclarer la variable d'environnement RUSTFLAGS comme ceci :

# Cibler la plateforme sur laquelle est construit le binaire
RUSTFLAGS="-C target-cpu=native"

# CPU Intel
RUSTFLAGS="-C target-cpu=skylake"

# CPU AMD
RUSTFLAGS="-C target-cpu=znver2"
Devoxx - Java vs Rust

Résultats surprenant mais expliqués.

En substance, sur les applications codées pour le benchmark, si Java consomme beaucoup plus de mémoire que Rust (entre x3 et x10 en fonction que nous soyons sur Hotpot ou GraalVM), Java est globalement plus rapide que Rust.

Ce résultat qui paraît contre intuitif s'explique par les optimisations que la JRE effectue au runtime, ce qu'un compilateur ne peut pas faire.

Par contre, les temps de démarrage de Rust sont bien rapides que ceux de Java Hotspot mais comparable à GraalVM.

Enfin, si GraalVM consomme 2 à 3 fois moins de mémoire que Java Hotspot (mais toujours plus que Rust), ses performances sont moindre car la compilation native empêche d'effectuer les optimisations au runtime.

En dehors des temps de chargements qui sont forcément plus longs en Java, il faudrait voir ce que la compilation native au runtime pourrait apporter avec GraalVM + Truffle + Substrate VM.

Donc si vous avez :

  • Beaucoup de RAM
  • Pas de problème avec le temps de démarrage
  • Un besoin de grosses perfs sur les requêtes de type I/O

Alors Java est le choix à privilégier sur Rust et ce résultat est totalement contre-intuitif ! /O\ #Bluffée

Elon Musk sur Twitter : "Twitter will open source all code used to recommend tweets on March 31st" / Twitter - Liens en vrac de sebsauvage - Le Hollandais Volant

@Timo je pense qu'Elon Musk veut démontrer aux annonceurs que le nouvel algorithme de Twitter n'a pas de biais idéologiques ou politiques pour les rassurer (au regard de sa posture idéologique personelle qui peut faire peur).

Si c'est la cas, alors c'est plutôt malin et lucide de sa part.

Rust - Publier un crate sur un répo Gitea privé avec Cargo

Dans votre fichier ~/.cargo/config.toml ajouter la configuration suivante

[registry]
default = "gitea"

[registries.gitea]
index = "https://mycompany.com/gitea/my-rust-repository.git"

[net]
git-fetch-with-cli = true

N.B : Je publie cette configuration ici mais je ne l'ai pas encore testée.

[Solved] How to Fix GRUB Load Errors and Recover Data?

Le MBR de votre serveur a lâché (ça vient de m'arriver à cause d'une coupure de courant), Voici quelques pistes pour le restaurer.

https://www.sammyfisherjr.net/Shaarli/?mEHoqw - OpenNews

J'avais retiré OpenNews de mon flux parce que je trouvais que les postes de certains anonymes transformaient les rivers en Twitter. Aujourd'hui je me suis dis, bon ça fait quelques mois par curiosité jetons-y un oeil. Premier post et l'état d'esprit nauséabond est toujours là :

qu'il est beau d'être comme tant d’abruti, de ce concevoir cela dans des mondes irréel .
Alors que pour 2 000 000 000 de gens c'est la réalité du quotidien pour trouver de réel nourriture ou de subsistance sans parler de l'eau principe au-dessus....

Ce message est insultant et méprisant. Cependant on voit que l'auteur y signale au monde l'immensité de sa vertu en rabaissant un message positif de quelqu'un qui tente de s'évader un peu et de nous faire partager cela.

D'ailleurs, ce même individu ne prend aucun recule pour constater qu'il consomme ressources et énergie, à poster des choses anecdotiques, alors que celles-ci serviraient bien mieux les 2 000 000 000 de gens dont il parle et qui vivent une réalité tragique. Mais lui il peut, puisque lui sa posture est vertueuse... 😭

Ce monde est cruel. La sélection naturelle est un système de prédation impitoyable. Les acides aminés nous permettant de vivre ont déjà tous été consommés il y a plusieurs millions d'années et pour absorber ce dont nous avons besoin pour vivre, il faut forcément tuer quelque chose. Chaque humain est en concurrence féroce avec son prochain mais également avec le règne animal pour sa propre survie 💀 et nos sociétés ne sont que des illusions qui masquent ce réel.

Au final, dans cette fresque épouvantable certains s'échappent comme ils le peuvent en s'imaginant un univers différent et en y vivant des expériences fantaisistes. Mais pour d'autres, l'échappatoire consiste à rabaisser, mépriser et punir. J'ai de la peine pour ces derniers... Ou pas 🙄

@Sammy je comprends ce que tu as essayé de faire ressentir, bisou <3

Je réactive mon filtre anti-opennews.

JVM Tuning Using jcmd - DZone

Une introduction à ce qu'il est possible de mesurer à l'aide de l'utilitaire jcmd embarqué dans les JDK.

Guide to the Most Important JVM Parameters | Baeldung

Tout est dans le titre. Je suis en train de comparer plusieurs choses actuellement :

  1. Le surcoût que représente la JRE 17 sur une application Kotlin.

  2. Le gain qu'apporte une JRE 17 custom produite à l'aide de l'utilitaire JLink.

  3. Les performances de (1) et (2) face à la même application codée en Rust, en termes de consommation mémoire et d'opérations par seconde.

  4. Les nouvelles options que la JRE prends en paramètre et leurs effets sur le CPU, la mémoire et le débit.

Le tuning de JRE a toujours été compliqué, mais pour obtenir une JRE de 30 Mo optimisée comme il faut, il y a encore plus de choses à connaître et comprendre qu'avant. Damned !

Cargo - Comment importer une dépendance privée depuis un autre projet Rust

Pour faire simple, l'équivalent du .m2/repository ou du répertoire cache des node_modules de npm n'existe pas en Rust / Cargo 😭

En résumé

  • Soit il faut publier ses libs privées sur crates.io et donc les rendre publiques 🤬.
  • Soit il faut installer l'équivalent d'un Artifactory perso quelque part 🤮.
  • Soit il faut bidouiller / ruser 😩.

Parmi les bidouilles il y en a deux dont une acceptable je pense. Il faut déclarer le chemin vers le repo Git de la dépendance et non le nom et la version de la dépendance dans le fichier Cargo.toml du projet.

Ce qui nous donne

[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git", branch = "1.0.0" } 

À la place de

[dependencies]
regex = "1.0.0"

Le paramètre branch peut être une branche ou un tag. S'il n'est pas présent alors c'est le dernier commit sur la branche master qui sera utilisé.

Quid des fanatiques qui utilisent l'appellation main à la place de master ? #DoNotCare

L'autre option consiste à déclarer le chemin relatif ou se trouve la dépendance (ici regexp) sur notre disque dur dans le fichier Cargo.toml à la place du répo Git.

Ce qui rend le build non portable d'un développeur à l'autre puisque dépendant de la hiérarchie des dossiers, donc je vais éviter d'en parler.

Rust Playground - Tester du code Rust dans votre navigateur
Le Rust Book en français - Le langage de programmation Rust

Il s'agit d'une traduction en français de la documentation / du livre disponible sur https://doc.rust-lang.org/stable/book/ et qui porte sur le langage Rust.

Cela devrait aider mes petits jeunes à mieux comprendre le langage. D'ailleurs je pense que je vais reprendre ma série de posts afin de synthétiser quelques concepts pour les néophytes.

Liste des ZFU de France (décrêt du 15 mai 2007)
Union Européenne – Les VRAIES raisons de la réforme des retraites, et son origine cachée

Le debunk est long (1h) mais il en vaut la peine. En substance, oui c'est bien l'Union Européenne qui est à la base de la procédure de réforme destruction des retraites françaises.

J'ai beaucoup aimé le debunk du "fact-checking" de RTL qui n'a fait que mentir pour couvrir l'UE dans ses exigences néo-libérales (faudrait pas perdre son financement annuel en même temps).

J'espère que certains amis côtés NUPES vont enfin ouvrir les yeux sur l'UE et sa prédation omniprésente des droits des citoyens, de leur libertés, de leurs protections sociales et de leur niveau de vie aussi et d'une manière générale.

Les dirigeants des gauches européistes (et ceux des droites ne nous trompons pas) jouent le jeu des marchés financiers et des milliardaires qui ont financé leurs campagnes.

Il est indispensable que les citoyens de gauche réalisent que des traîtres dirigent les mouvements afin que nous puissions réinstaurer le projet social qui était le notre.

An Interactive Introduction to Fourier Transforms

Oh un cours sur les transformées de Fourier ! Cela fait déjà 9 ans déjà que je n'ai plus codé de signaux. C'est fou comme le temps passe vite !

Merci à Dukeart pour le lien