- Dans OpenJDK,
ThreadMXBean.getCurrentThreadUserTime()a été remplacé par un appel àclock_gettime()au lieu d’un parsing de fichier/proc, avec à la clé jusqu’à 400x de gain de performances - L’implémentation précédente suivait un chemin d’E/S complexe en ouvrant, lisant et analysant le fichier
/proc/self/task/<tid>/stat - La nouvelle implémentation exploite l’encodage binaire de
clockid_tdans le noyau Linux pour ajuster les bits de poids faible de l’identifiant obtenu viapthread_getcpuclockid()et interroger directement uniquement le temps utilisateur - Les benchmarks montrent que le temps moyen par appel passe de 11μs à 279ns, puis environ 13 % de mieux après l’application d’un fast-path côté noyau
- C’est un exemple qui montre qu’une optimisation est possible au-delà des contraintes de POSIX grâce à une compréhension de l’ABI interne de Linux
Problèmes de l’implémentation existante
getCurrentThreadUserTime()ouvrait le fichier/proc/self/task/<tid>/statpour analyser les 13e et 14e champs et calculer le temps CPU utilisateur- cela nécessitait plusieurs étapes : construction du chemin, ouverture du fichier, lecture dans un tampon, parsing de chaîne, appel à
sscanf(), etc. - le nom de commande pouvant contenir des parenthèses, la logique incluait aussi une recherche complexe du dernier
)avecstrrchr()
- cela nécessitait plusieurs étapes : construction du chemin, ouverture du fichier, lecture dans un tampon, parsing de chaîne, appel à
- À l’inverse,
getCurrentThreadCpuTime()n’effectuait qu’un seul appel àclock_gettime(CLOCK_THREAD_CPUTIME_ID) - D’après le rapport de bug de 2018 (JDK-8210452), l’écart de vitesse entre les deux méthodes atteignait 30 à 400x
Comparaison entre le chemin d’accès /proc et le chemin clock_gettime()
- La méthode
/procinclut plusieurs appels système et la génération de chaînes dans le noyau, commeopen(),read(),sscanf()etclose() - La méthode
clock_gettime()repose sur un seul appel système qui lit directement la valeur temporelle depuis la structuresched_entity - Sous charge parallèle, l’accès à
/procsubit des ralentissements accrus à cause de la contention sur les verrous du noyau
Nouvelle méthode d’implémentation
- Le standard POSIX définit que
CLOCK_THREAD_CPUTIME_IDdoit renvoyer le temps utilisateur + le temps système - Le noyau Linux encode le type d’horloge dans les bits de poids faible de
clockid_t00=PROF,01=VIRT(utilisateur uniquement),10=SCHED(utilisateur + système)
- En remplaçant les bits de poids faible du
clockidobtenu viapthread_getcpuclockid()par01, il devient possible de basculer vers une horloge dédiée uniquement au temps utilisateur - Le nouveau code supprime les E/S fichier et le parsing, et renvoie le temps utilisateur via un simple appel à
clock_gettime()
Résultats des mesures de performances
- Avant la modification, le temps moyen par appel était de 11,186μs ; après, il tombe à 0,279μs, soit une amélioration d’environ 40x
- mesure réalisée dans un environnement à 16 threads, cohérente avec la fourchette initialement rapportée de 30 à 400x
- Dans les profils CPU, les appels système liés à l’ouverture et à la fermeture de fichiers disparaissent, ne laissant qu’un seul appel à
clock_gettime()
Optimisation supplémentaire avec le fast-path du noyau
- Le noyau fournit un fast-path qui accède directement au thread courant lorsque le
clockidencode PID=0 - Si la JVM construit directement le
clockidau lieu d’utiliserpthread_getcpuclockid(), en y injectant PID=0, elle peut éviter la recherche dans l’arbre radix - Avec un
clockidconstruit manuellement, le temps moyen passe de 81,7ns à 70,8ns, soit environ 13 % d’amélioration supplémentaire - En contrepartie, cela dépend de détails d’implémentation internes du noyau, comme la taille de
clockid_t, avec un risque de perte de lisibilité et de compatibilité
Conclusion et enseignements
- La suppression de 40 lignes a éliminé un écart de performances de 400x, sans nouvelle fonctionnalité noyau, uniquement en exploitant la structure détaillée de l’ABI existante
- L’article souligne la valeur d’une lecture attentive du code source du noyau : POSIX garantit la portabilité, mais le code du noyau montre les limites du possible
- L’importance de reconsidérer les hypothèses existantes : analyser
/procétait autrefois raisonnable, mais c’est aujourd’hui inefficace - Ce changement sera intégré à JDK 26 (sortie prévue en mars 2026) et apportera un gain de performances automatique lors des appels à
ThreadMXBean.getCurrentThreadUserTime()
5 commentaires
En réalité, même en refondant complètement tout le code, obtenir un gain de 2 à 3 fois est déjà difficile ; alors atteindre jusqu’à 400 fois plus de performances en ne changeant que quelques lignes, c’est vraiment impressionnant.
Commentaires sur Hacker News
Je me suis rendu compte que la question « quel est le temps CPU utilisé par ce thread ? » est une opération bien trop coûteuse en pratique
Sans référence de niveau horloge atomique, il me semble difficile d’affirmer des valeurs absolues
clock_gettime()évite le changement de contexte via le vDSO. On en voit donc la trace dans la flamegraphCLOCK_VIRTouCLOCK_SCHED, par exemple, il faut toujours passer par un appel systèmeclock idCLOCK_THREAD_CPUTIME_IDfinit bien par passer dans le kernel, car il faut consulter latask structVoir le code source du kernel correspondant : posix-cpu-timers.c,
cputime.c,
gettimeofday.c
PERF_COUNT_SW_TASK_CLOCK, il est possible d’obtenir des mesures autour de 8 nsLe principe consiste à lire depuis une page partagée via
perf_event_mmap_page, puis à calculer le delta avec un appel àrdtscC’est peu documenté et il n’existe pratiquement pas d’implémentations open source
perf_eventet les permissions nécessaires, cela semble surtout adapté à des threads de longue durée de vieseqlockest nécessaire. Est-ce pour éviter qu’un changement de contexte ne se produise entre la valeur de la page etrdtsc?A priori, la structure semble consister à revérifier la valeur de la page après
rdtscet à réessayer si elle a changéÀ noter que
clock_gettimeest lui aussi un syscall virtuel basé sur vdsoclock_gettimen’est pas un syscall, il utilise le vdsoEn regardant seulement le code, tout peut sembler correct, mais avec une flamegraph on se dit souvent : « mais qu’est-ce que c’est que ça ?! »
J’y ai déjà découvert divers problèmes, comme des initialisations faites hors initialisation statique, ou un appel de logger d’une ligne qui déclenchait une sérialisation coûteuse
D’habitude, j’utilise le générateur HTML d’async-profiler, mais cette fois j’ai utilisé l’outil de Brendan pour produire un SVG unique
/proc, du profiling eBPF, et de l’histoire d’une ABI user-space peu documentéeJ’ai rassemblé les détails dans un billet sur mon blog
Tweet source
Le blog de Jaromir était lui aussi remarquable
Comment peut-on repérer ce genre de choses dans un projet ? J’ai l’impression qu’il serait difficile de s’en rendre compte simplement en faisant tourner une IA..
Quand je vois ce type de cas, je me dis que moi aussi j’aimerais apprendre et vivre ce genre d’expérience.
C’est impressionnant.
Je pense que ce n’est pas complètement faux, mais quand c’est lié au noyau, je pense que rien que le fait de se rendre compte que c’était lent a dû être vraiment difficile.