- En reconstruisant la même source de
jqet en changeant d’allocateur, le temps de traitement d’un GeoJSON de 500 Mo est passé de 4,606 secondes à 2,428 secondes, soit 1,90 fois plus rapide que le binaire Ubuntu - Le benchmark a été réalisé sur un Ryzen 9 9950X avec la parcel map de l’Alameda County Assessor, en extrayant
SitusCitypour la conditionTotalNetValue < 193000 - Une simple reconstruction a déjà apporté 2 à 4 % d’amélioration, et la combinaison
clang-18,-O3,-flto,-DNDEBUGa atteint 1,20 fois les performances du paquet Ubuntu - Le profilage a montré un coût important de l’allocation mémoire ;
TCMalloc,jemallocetmimallocont été comparés, et dans les essais avecLD_PRELOAD,mimallocs’est montré le plus rapide - La version finale liée avec
mimalloca aussi enregistré 0,755 seconde contre 1,424 seconde dans un autre cas de traitement d’un JSON de 2,2 Go, montrant que l’écart avec les builds par défaut des distributions peut être important selon la charge de travail
Charge de travail de référence et méthode de mesure
- L’outil de traitement JSON testé est
jq, avec en entrée un fichier GeoJSON de 500 Mo contenant la parcel map de l’Alameda County Assessor - La requête exécutée affiche le
SitusCitydes éléments de la liste de parcelles satisfaisant la conditionTotalNetValue < 193000.features[] | select(.properties.TotalNetValue < 193000) | .properties.SitusCity
- Le
/usr/bin/jqpar défaut d’Ubuntu prend environ 5 secondes lorsque le fichier est en cache, et les benchmarks détaillés ont été répétés avechyperfine - Pour réduire les variations à l’exécution, le processus a été épinglé au CPU logique 2 avec
taskset -c 2- Ce réglage vise à éviter l’impact des interruptions système sur le CPU 0 et de la migration entre CPU
Simple reconstruction de la même source
- Le
code source de jqutilisé par Ubuntu a été récupéré, puis configuré et compilé sans options particulières - Cette simple reconstruction suffit à rendre le binaire environ 2 à 4 % plus rapide que le paquet binaire Ubuntu
- Binaire reconstruit : moyenne de 4,517 secondes
/usr/bin/jqd’Ubuntu : moyenne de 4,641 secondes- Soit environ 1,03 fois les performances
Application de clang et de flags d’optimisation
- À l’étape suivante,
clang-18, un niveau d’optimisation plus élevé, LTO, ainsi que des flags liés au débogage et au profilage ont été appliqués ensemble - Les flags clés ayant influé sur les performances sont
-O3,-fltoet-DNDEBUG-O3utilise un niveau d’optimisation supérieur à-O2-fltoactive l’optimisation au moment de l’édition de liens-DNDEBUGréduit le coût des assertions, qui ressortait fortement dans le profilage
- Voici un exemple de configuration appliquée
CC=clang-18LDFLAGS="-flto -g -Wl,--emit-relocs -Wl,-z,now -Wl,--gc-sections -fuse-ld=lld"CFLAGS="-flto -DNDEBUG -fno-omit-frame-pointer -gmlt -march=native -O3 -mno-omit-leaf-frame-pointer -ffunction-sections -fdata-sections"
- Ce build était 1,20 fois plus rapide que le binaire Ubuntu
- Reconstruction optimisée : moyenne de 3,853 secondes
/usr/bin/jqd’Ubuntu : moyenne de 4,631 secondes
Expériences de remplacement de l’allocateur
jqest un programme C complexe, et le profilage a montré que l’allocation mémoire constituait le coût principal- Une première reconstruction a été faite en liant
TCMalloc, fourni sous forme de paquet Ubuntu-L/usr/lib/x86_64-linux-gnu -ltcmalloc_minimala été ajouté àLDFLAGS- Binaire reconstruit : moyenne de 3,253 secondes
/usr/bin/jqd’Ubuntu : moyenne de 4,611 secondes- Résultat : 1,42 fois plus rapide que le binaire Ubuntu
- Remplacer uniquement l’allocateur via
LD_PRELOADavec le binaire Ubuntu par défaut permet aussi une certaine amélioration- Par défaut : moyenne de 4,601 secondes
- TCMalloc préchargé : moyenne de 4,082 secondes
- Soit 1,13 fois plus rapide que la valeur par défaut
Préchargement dynamique et configuration THP
jemalloc,mimallocetTCMalloc, fournis par Ubuntu, ont été comparés avecLD_PRELOAD- Cette comparaison a été obtenue après définition des variables d’environnement suivantes
MIMALLOC_LARGE_OS_PAGES=1MALLOC_CONF="thp:always,metadata_thp:always"GLIBC_TUNABLES=glibc.malloc.hugetlb=1
- Au final, mimalloc est le plus rapide
- glibc par défaut : moyenne de 4,123 secondes
- TCMalloc préchargé : moyenne de 4,130 secondes
- jemalloc préchargé : moyenne de 3,510 secondes
- mimalloc préchargé : moyenne de 3,154 secondes
- L’activation de THP profite à la fois à l’allocateur glibc, à jemalloc et à mimalloc
THP + mimallocest 31 % plus rapide queTHP + glibc, et 48 % plus rapide que la configuration par défaut de glibc
Résultat final du build lié avec mimalloc
- Le préchargement dynamique n’étant pas jugé idéal sur le plan des performances, la dernière étape a consisté à reconstruire
jqen le liant àmimalloc - Le build final était 1,90 fois plus rapide que le paquet binaire Ubuntu
- Reconstruction avec
mimalloc: moyenne de 2,428 secondes /usr/bin/jqd’Ubuntu : moyenne de 4,606 secondes- Chaque benchmark repose sur 10 exécutions
- Reconstruction avec
- Le même build a aussi été utilisé avec une autre application
- Traitement de 2,2 Go de JSON répartis dans 13 000 fichiers
rusha été utilisé pour la parallélisationjqreconstruit avecmimalloc: 0,755 secondejqdu paquet Ubuntu : 1,424 seconde
- Dans ce cas distinct également, le gain de vitesse approche presque 2 fois
1 commentaires
Avis sur Hacker News
Les titres putaclic du genre « reconstruire un paquet Ubuntu et le rendre 90 % plus rapide en changeant l’allocateur mémoire » donnent envie de donner un coup à travers TCP/IP. Ce n’était qu’un seul paquet, et certaines améliorations ne venaient même pas de la recompilation.
Cela dit, il m’est déjà arrivé d’injecter jemalloc dans un programme avec
LD_PRELOADpour remplacer l’implémentation demalloc, et le résultat a été plutôt bon. Je n’ai pas mesuré les performances, mais l’utilisation mémoire de l’application s’est stabilisée et un problème qui ressemblait à une fuite mémoire a disparu. En réalité, il est très probable que ce n’était pas un problème de l’application elle-même, mais de la fragmentation mémoire dumallocstandard.free()est appelé, sauf situation exceptionnelle, la mémoire n’est pas réellement libérée de l’extérieur.Plus il y a de threads et de cœurs CPU, plus le problème s’aggrave. Une solution simple consiste à définir la variable d’environnement « magique »
MALLOC_ARENA_MAX=2pour limiter le nombre de caches. Une autre méthode consiste à faire appeler régulièrementmalloc_trim()par l’application pour vider les caches, mais cela nécessite de modifier le code source.https://www.joyfulbikeshedding.com/blog/2019-03-14-what-caus...
À l’inverse, je suis content qu’ils ne compilent pas avec
-O3, potentiellement source de bugs. Cela peut être intéressant pour certains éléments où les performances sont critiques, mais je ne voudrais pas que tout le système soit compilé avec-O3.Il y a longtemps, j’ai commencé à compiler Mozilla et mon noyau Linux selon mes préférences, et j’obtenais généralement un gain de performances raisonnable. Tout l’objectif d’une distribution comme Gentoo Linux, par exemple, est justement de tirer des gains de performances en compilant tout depuis les sources avec des optimisations.
jq,grep,ffmpegouocrmypdf, et que ces utilitaires Unix génériques sont souvent construits comme des cibles générales, pas pour une application particulière.L’ingénierie est affaire de compromis. Dans l’article, l’essentiel des gains vient de la spécialisation de l’allocateur mémoire. Il faut garder à l’esprit que certains projets sont multithreadés, qu’ils allouent dans un thread, écrivent les données dans un autre et les libèrent parfois dans un troisième
L’allocateur doit gérer cela, donc une accélération dans un projet peut se transformer en crash dans un autre. La stratégie de réallocation pose aussi problème. Certains programmes préallouent et ne touchent plus jamais à
malloc, tandis que d’autres libèrent et réallouent en permanence. La qualité de la gestion de la fragmentation compte, tout comme le fait que le temps de fonctionnement soit de 10 secondes ou de 10 ans. Parfois, le choix de l’allocateur fait la différence entre stabilité à long terme et vitesse à court termeJ’ai expérimenté plusieurs allocateurs en créant un éditeur vidéo qui met en cache des images pendant des tests en 4K. À 32 Mo par image et 60 i/s, cela fait presque 2 Go par seconde pour une seule piste. On atteint très vite les limites de l’allocateur, et on se rend compte qu’au moins l’allocateur glibc par défaut est le meilleur pour la stabilité à long terme. Mais dans les benchmarks courts, c’est le plus lent
Bien sûr, l’ampleur des gains peut varier, mais les benchmarks montrent généralement une amélioration globale. Que cela ait du sens pour une application donnée est une tout autre question. Quant aux crashs, ce sont tous des allocateurs généralistes multithreadés, ils ne se comportent donc pas différemment de glibc à cet égard, et les bugs pourraient tout aussi bien exister dans glibc
DEFAULT_MMAP_THRESHOLD_MAX, qui vaut 32 MiB sur les plateformes 64 bits, si bien qu’il est impossible de convaincre glibc de les mettre en cache, comme le documente le manuel demalloptÀ chaque fois, il demande directement de la mémoire au noyau avec
mmapet la rend avecmunmap. Ces appels système sont un peu lents et, dans mon cas, le coût des défauts de page sur chaque page mémoire au premier accès est trop élevé pour atteindre les objectifs de performance. La solution est vraiment simple : pour les images vidéo uniquement, utiliser une liste libre maison au-dessus d’un allocateur généraliste ou demmap. Comme les allocations de taille exactement identique se répètent de manière très stable, cela fonctionne bien[1] Un peu moins de 64 MiB au format UYVY, et un peu moins de 48 MiB au format I420
Mais en lisant ce commentaire parent, je me demande si je n’ai pas complètement mal compris l’article. Le ton donne l’impression que ce que propose le gist est quelque chose qu’il ne faut absolument jamais faire, une suggestion horrible qui passe à côté de toute cette complexité. Est-ce que quelqu’un peut m’aider à comprendre si le gist d’origine est un bon texte, s’il soulève des points valables, ou s’il n’a aucune valeur ? Jusqu’à lire ce commentaire, je le trouvais utile, mais je me rends compte que je ne suis peut-être pas assez compétent pour faire la différence
Si vous utilisez l’algorithme de compression adapté aux données, les gens vous expliqueront pourquoi vous êtes stupide et auriez dû en choisir un autre. Récemment, j’ai dû compresser une longue chaîne JSON particulière pour la mettre dans Dynamo, et après avoir testé minutieusement tous les algorithmes populaires, Brotli était largement devant. Cela n’a pas empêché presque toutes les personnes de passage de me dire que zlib était meilleur. C’est parfois assez épuisant
Gentoo Linux est, dans les faits, une distribution conçue pour ce genre de personnes, afin de pouvoir optimiser leur machine Linux pour leurs propres usages
Après la configuration initiale, elle est assez simple et facile à utiliser. Je me souviens m’être fait pas mal d’amis sur le canal Gentoo Linux de Matrix, c’était une époque sympa
https://www.gentoo.org/
Fait amusant, les premières versions de ChromeOS étaient essentiellement une installation Gentoo Linux personnalisée. Je ne sais pas si ChromeOS utilise encore Gentoo Linux en interne aujourd’hui
J’utilise Gentoo depuis 20 ans, mais je ne l’ai jamais utilisé pour les performances. Gentoo est excellent quand on sait comment on veut que le système se comporte, et il aide à y parvenir
L’objectif de Gentoo est d’avoir un système d’exploitation qui compile tous les programmes depuis les sources plutôt que d’utiliser des paquets binaires précompilés. Cela permet des optimisations avancées et une personnalisation poussée, mais cela signifie aussi que même les composants les plus fondamentaux, comme le noyau, doivent être compilés depuis les sources. Dans la communauté Linux, Gentoo est réputé être un système d’exploitation très complexe à cause de son processus d’installation exigeant. Une installation Gentoo de base démarre directement sur une invite de commande, et l’utilisateur doit partitionner les disques lui-même, télécharger puis décompresser un paquet appelé « Stage 3 tarball », installer les paquets manuellement et construire le système. Les nouveaux utilisateurs, ou ceux qui manquent d’expérience, ne savent souvent pas quoi faire lorsqu’ils arrivent dans l’installateur et qu’il n’y a pas d’interface graphique. Les membres de /g/ exagèrent souvent la valeur de Gentoo pour pousser de nouveaux utilisateurs à tenter l’installation
Il peut y avoir un ou deux accrochages, mais avec une expérience Linux globale suffisante, on a de bonnes chances d’aller dans la recette de build, de la corriger, de la faire fonctionner comme nécessaire, puis de contribuer les modifications en amont. C’est le résultat d’une focalisation obsessionnelle sur le minimalisme et du refus de toute forme de suringénierie. C’est aussi ce qui m’a manqué pendant toutes mes années sous Gentoo. Avec Gentoo, je finissais toujours par bricoler des USE flags et des masques de paquets d’une manière qui n’aidait pas vraiment les autres utilisateurs. Le système de build était tellement complexe qu’au fil des années, il était trop difficile de vraiment le maîtriser, de corriger les problèmes à la racine et de contribuer en amont. Void peut aussi être une base idéale quand on ne veut pas compiler tout le système depuis les sources, mais qu’on veut mélanger les binaires fournis par la distribution avec des paquets compilés soi-même depuis les sources
Je suis ensuite passé à ArchLinux, et dans l’ensemble ça m’a convenu. Si on utilise un processeur assez standard, je ne pense pas que Gentoo apporte un si grand avantage
En faisant cela, on sort non seulement des mises à jour de sécurité de
jq, mais aussi de celles d’onigurama, sa dépendance pour l’analyse des expressions régulières. Il y a déjà eu par le passé une mise à jour de sécurité d’onigurama, et si cela se reproduit, on pourrait devenir vulnérable.jqest souvent utilisé pour analyser du JSON non fiableIl s’agissait d’une « mise à jour de sécurité : correction de plusieurs déréférencements de pointeurs invalides, corruptions mémoire par écriture hors limites et dépassements de tampon sur la pile », concernant les CVE-2017-9224, CVE-2017-9226, CVE-2017-9227, CVE-2017-9228 et CVE-2017-9229
libonig5, et sera mis à jour normalementJe ne cherche pas à minimiser la valeur du système des CVE, mais il est difficile de nier que l’impact réel varie énormément d’une découverte à l’autre
Ça fait un moment que je n’ai pas touché à ce genre de choses, mais dans mon souvenir, dès qu’on dépasse les options utilisées par les développeurs amont, on s’expose à des bugs étranges et à une indifférence abyssale quand ces bugs apparaissent. Ici, je parle bien des développeurs amont, pas des mainteneurs de paquets des distributions
Je n’ai jamais utilisé de
mallocautre que celui de la libc, mais j’imagine que le même principe s’appliqueMais si tout le monde fait cela, on obtient une monoculture, et une monoculture est fragile et mauvaise. Le code ne devient un peu plus robuste que parce qu’il est compilé dans d’autres contextes : autres plateformes, compilateurs, options, bibliothèques, etc. Un bug que la plupart des gens n’ont pas déclenché parce que leur plateforme ou leurs options de build passent par hasard juste à côté du piège reste un bug, et il est préférable pour le code de le trouver et de le corriger. En tant qu’individus, nous gagnons tous à ce que le code devienne globalement plus robuste plutôt que fragile
Bien sûr, je pensais aussi que
-march=nativeétait la principale amélioration que j’observais, mais cet article montre que ce n’est pas forcément le cas. Les applications qui utilisent des nombres à virgule flottante ont peut-être aussi davantage d’aspéritésCela revient plutôt à l’avoir recompilé avec un autre allocateur qui donne de bons résultats dans les benchmarks pour un workflow précis
mallocde glibc. Le fait que les distributions continuent d’utiliser lemallocde glibc au lieu de mimalloc ou jemalloc relève quasiment du manquement professionnelmallocde glibc est bon ?Je me demande comment les performances se comparent à celles de ce clone de
jqbasé sur Rustcargo install --locked jaqPour activer des optimisations adaptées à une famille de CPU donnée, on peut aussi ajouter
RUSTFLAGS="-C target-cpu=native".cargo installest une fonctionnalité sous-estimée de Rust, parfaitement adaptée au genre d’usage décrit dans l’article. Comme l’outil est compilé depuis les sources, on peut choisir des fonctionnalités ou instructions propres à la plateforme qui ne sont généralement pas incluses dans les binaires afin de rester compatibles avec de vieux CPU. Pas besoin non plus de cloner le dépôt ni de comprendre comment le build fonctionne : tout vient avecjaq[1] etyq[2] sont mes choix quand j’utilisejqet que j’ai besoin d’un gain de performances rapide et facile[1] https://github.com/01mf02/jaq
[2] https://github.com/mikefarah/yq
jqetgojq, avec ma solutionjqpour l’AoC 2022 day 13 comme testhttps://gist.github.com/oguz-ismail/8d0957dfeecc4f816ffee79d...
Il reste encore derrière les deux
cargo installdispose aussi d’un flag--gitpermettant d’indiquer l’URL du dépôt. C’est utile lorsqu’il n’existe pas de paquet public, ou qu’on veut un commit récent qui n’a pas encore été publiéJe l’ai déjà utilisé plusieurs fois, notamment comme moyen simple d’installer rapidement des outils bricolés pour mon usage perso après les avoir poussés dans un dépôt, sans avoir à mettre en place un processus de release ni à copier des binaires à la main sur mes machines personnelles, tout en gardant la trace exacte du commit utilisé pour le build
Si l’on veut vraiment faire cela, il suffit de demander à Ubuntu de récupérer le paquet source exact voulu. Dans ce cas, on peut utiliser
apt-get source jqEnsuite, on entre dans le paquet et on recompile comme on veut. On peut aussi le repackager pour le distribuer ou l’archiver. On obtient ainsi un résultat bien plus proche de l’Ubuntu amont, au lieu de récolter toute une série d’erreurs bizarres et d’incohérences
Le titre prête à confusion. Cela veut dire 90 % du temps gagné, et en réalité c’est environ 45 % plus rapide
C’est un peu intéressant si l’on s’intéresse à la façon dont on utilise le langage. On pourrait aussi dire qu’on fait 90 % de travail en plus dans le même temps, ce qui correspond à d’autres unités de vitesse courantes, comme les miles par heure, les mots par minute ou les bits par seconde. Mais en performance informatique, la convention consiste à mesurer le temps nécessaire pour une quantité de travail fixe. Sans doute parce que, la plupart du temps, la quantité de travail est fixe et ce qui varie, c’est le temps d’attente. C’est exactement le cas dans cet article de blog, d’où le fait de mettre le temps au numérateur. L’article lui-même est très intéressant et bien écrit, mais ce n’est pas 90 % plus rapide
Et si l’on utilisait une unité de temps, on n’emploierait pas le mot « faster ». « 45% less time » et « 45% faster » sont des affirmations très différentes, et les deux ont du sens en programmation comme ailleurs
Quand on dit qu’on a « réduit quelque chose de N % », on suppose généralement que ces N % portent sur la chose réduite elle-même, pas sur une autre valeur
En lisant qu’un changement aussi simple pouvait apporter un gros gain de vitesse, ma première réaction a été de penser qu’il fallait en informer les auteurs de jq. Il peut y avoir des pièges à éviter, et après test ils pourraient peut-être rendre l’outil plus rapide pour tout le monde
Quel que soit le résultat, un simple signalement semble utile. Pourtant, l’article ne semble même pas envisager cette option, et je ne la vois pas non plus dans les commentaires ici. Est-ce que je rate quelque chose ?
Je ne sais pas non plus si l’allocateur glibc y est le standard
https://en.m.wikipedia.org/wiki/Clear_Linux_OS