- Le point de départ était un signalement indiquant que la liaison Python d’Apache OpenDAL lisait les fichiers plus lentement que
open().read()intégré à Python, mais le goulot d’étranglement ne venait ni d’OpenDAL ni de PyO3 eux-mêmes - Dans un benchmark de lecture d’un fichier de 64 MiB,
python-fs-readétait mesuré à environ 15~19 ms, tandis que lestd::fsde Rust et une implémentation en C tournaient autour de 23 ms, donnant l’impression que Rust/C étaient plus lents que Python - En remontant avec
strace,eBPFetperf, la différence s’est révélée liée à l’offset du tampon de destination du syscallreadà l’intérieur de la page, avec une dégradation de performances reproductible près de0x10 - Un comportement similaire a été confirmé sur des AMD Ryzen 9 5900X, Ryzen 7 5700X et Ryzen 9 5900HX, et l’indice clé était la performance d’exécution de
rep movsbdans le_copy_to_iterdu noyau - Python n’est pas intrinsèquement plus rapide : le résultat venait d’un bug CPU lié à FSRM/
rep movsbsur AMD Zen 3, combiné par hasard à des offsets mémoire particuliers ; l’amélioration observée avecjemallocvenait elle aussi d’un autre offset, pas de l’allocateur lui-même
Un benchmark étrange parti de la liaison Python d’OpenDAL
- Apache OpenDAL est une couche d’accès aux données permettant de lire et d’écrire de façon unifiée sur plusieurs services de stockage, et sa liaison Python est fournie via PyO3
- Un utilisateur a signalé qu’un code lisant un fichier de 150 Mo via la liaison Python d’OpenDAL était plus lent que la lecture de fichier intégrée à Python
open(...).read()intégré à Python, 100 fois :4.470868484000675- Liaison Python OpenDAL, 100 fois :
8.993250704006641
- Même sur une lecture simplifiée d’un fichier de 64 MiB, la liaison OpenDAL restait plus lente
python-fs-read: moyenne de 15.9 mspython-opendal-read: moyenne de 32.9 ms- La lecture intégrée de Python était mesurée 2.07 fois plus rapide que la liaison OpenDAL
Enquête jusqu’à Rust OpenDAL puis std::fs
- Même en implémentant la même logique avec le service
fsd’OpenDAL en Rust, le résultat restait plus lent que la lecture intégrée de Pythonrust-opendal-fs-read: moyenne de 23.8 mspython-fs-read: moyenne de 15.6 ms- La lecture intégrée de Python était mesurée 1.52 fois plus rapide que l’implémentation Rust OpenDAL
- Le service
fsd’OpenDAL utilisestd::fsde Rust ; une implémentation distincte basée directement surstd::fsa donc été écrite pour vérifier le coût propre à OpenDAL - La même tendance s’est confirmée avec l’implémentation directe en Rust
std::fsrust-std-fs-read: moyenne de 23.1 mspython-fs-read: moyenne de 15.2 ms- La lecture intégrée de Python était mesurée 1.52 fois plus rapide que
std::fsde Rust
Les syscalls et mmap vus avec strace
- L’analyse avec
stracea montré que Rust comme Python utilisaientmmappour allouer de gros tampons - L’exécution de Rust
std::fssuivait le flux consistant à ouvrir/tmp/file, lire 64 MiB en une fois, appelerreadpour vérifier l’EOF, puis fermer le fichier - La lecture intégrée de Python exécutait davantage de syscalls, comme
newfstatat,ioctletlseek, mais le temps total restait plus court - L’appel
mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)n’était pas un mapping de fichier mais une allocation de mémoire anonyme67112960correspond à 64 MiB plus 4 KiBMAP_ANONYMOUSdésigne une allocation mémoire sans lien avec un fichier
- Le build par défaut de Rust pour
x86_64-unknown-linux-gnuutilise lemallocdeglibc, etglibcpeut utilisermmappour les grosses allocations
Rust accéléré par jemalloc et conclusion intermédiaire renversée
- En remplaçant l’allocateur global de Rust par
jemallocator::Jemalloc, le code devenait plus rapide que Pythonrust-std-fs-read-with-jemalloc: moyenne de 9.7 mspython-fs-read: moyenne de 15.8 ms- L’implémentation Rust avec jemalloc était mesurée 1.64 fois plus rapide que Python
- À ce stade,
mmapou l’allocateur mémoire par défaut semblaient être la cause, mais cette interprétation a ensuite été corrigée dans une mise à jour - D’après la mise à jour du 2023-12-01, la différence ne venait pas du fait que
jemalloc,pymallocoumimallocseraient intrinsèquement plus rapides queglibc malloc - La vraie différence venait de l’offset dans la page du tampon créé par l’allocateur
rust-std-fs-read: lecture à un offset de0x10depuis l’adresse de départ dummaprust-std-fs-read-with-jemalloc: lecture à un offset de0x740depuis l’adresse de départ dummap
- La zone problématique a été ramenée à la plage
0x00..0x10dans la page, et le même problème peut être reproduit avecjemalloc
Un problème plus reproductible selon la machine que selon la configuration logicielle
- Au fil de la discussion, il est apparu que le phénomène où Rust semblait plus lent que Python était particulièrement marqué sur la machine de l’auteur
- Le CPU de l’auteur était un AMD Ryzen 9 5950X 16-Core Processor, avec une configuration mémoire DDR4 3200 MT/s en DIMM de 16 Go
- Même en modifiant plusieurs réglages, l’écart de performance relatif ne disparaissait pas
- Réactiver
mitigations=offdu noyau Linux ne changeait pas le résultat - Passer Transparent Hugepage à
always,madviseounevermodifiait les valeurs absolues, mais pas le ratio relatif - Fixer l’exécution sur un cœur CPU précis avec
core_affinitydonnait le même résultat
- Réactiver
- Les mesures de latence du syscall
readviaeBPFmontraient elles aussi un Rust plus lent- Python
read file: 8,134,049 ns - Rust
std::fsread file: 24,636,975 ns
- Python
- Les observations rendaient difficile d’expliquer l’écart par OpenDAL, PyO3 ou la bibliothèque standard de Rust seulement : le temps était déjà perdu au niveau du syscall
L’indice de l’offset mémoire révélé par l’implémentation en C
- Même en implémentant la même lecture de fichier de 64 MiB en C avec
fopen/malloc/fread, le résultat restait plus lent que Pythonc-fs-read: moyenne de 23.8 mspython-fs-read: moyenne de 19.1 ms- La lecture intégrée de Python était mesurée 1.25 fois plus rapide que l’implémentation C
- En vérifiant les adresses de pointeurs avec
strace -e raw=read,mmap, on a vu que l’offset de départ du tampon différait entre C et Python- C :
readà un offset de0x10depuis l’adresse renvoyée parmmap - Python :
readà un offset de0x30depuis l’adresse renvoyée parmmap
- C :
- En ajustant cet offset de la même manière dans l’implémentation C, les performances se sont nettement améliorées
c-fs-read-with-offset: moyenne de 8.9 ms- 2.15 fois plus rapide que Python, 2.68 fois plus rapide que l’implémentation C initiale
- Le problème a aussi été reproduit sur AMD Ryzen 9 5900X et AMD Ryzen 7 5700X
- Un phénomène similaire a également été signalé dans la communauté Rust via Std::fs::read slow?, où le lien entre offset mémoire et performance des syscalls a été pointé du doigt
L’analyse perf pointe vers rep movsb
- Un développeur noyau a reproduit
c-fs-readet sa version avec offset sur unAMD Ryzen 9 5900HX, puis les a analysés avecperf - Selon la présence ou non de l’offset, les valeurs de
L1-dcache-prefetchesetL1-dcache-loadsvariaient fortement- Sans offset :
L1-dcache-loadsd’environ 127,845,213,L1-dcache-prefetchesd’environ 1,843,493 - Avec offset :
L1-dcache-loadsd’environ 13,965,813,L1-dcache-prefetchesd’environ 395,578
- Sans offset :
- Le hotspot suivait le chemin de lecture du noyau, de
shmem_file_read_iteràcopy_page_to_iter, puis_copy_to_iter - L’assembleur clé à l’intérieur de
_copy_to_iterétaitrep movsb, sur lequel se concentraient la plupart des échantillons - Les analyses ultérieures ont montré que, plus que le préfetch L1 lui-même, l’indice important était que
rep movsbse comportait mal sur des données alignées sur la page, et mieux quand cet alignement était rompu
FSRM et le problème sur AMD Zen 3
- Le rapport de bug glibc Ubuntu partagé, Terrible memcpy performance on Zen 3 when using rep movsb, traite lui aussi de ce problème de performance de
rep movsb - L’exemple donné dans ce rapport explique qu’une copie de 2113 octets via le chemin
rep movsbatteint environ 3.2 GB/s, alors qu’en changeant la taille à 2111 octets elle dépasse 100 GB/s - FSRM signifie Fast Short REP MOV, une fonctionnalité destinée à accélérer
rep movsbetrep movsd - FSRM est une fonctionnalité apparue chez Intel puis introduite aussi chez AMD, et sur les CPU qui déclarent la prendre en charge,
glibcl’utilise par défaut - En conséquence, Python n’est pas intrinsèquement plus rapide que C/Rust ; il s’agissait d’un chemin de lecture ralenti à certains offsets mémoire à cause d’un bug CPU sur AMD
Mise à jour : connaissance du problème côté AMD et réponse de glibc
- D’après la mise à jour du 2023-12-01, AMD semblait connaître ce bug depuis 2021
- Après la publication du billet, plusieurs lecteurs ont transmis le lien à AMD, ce qui laisse penser qu’AMD est au courant du problème
- L’auteur estime qu’AMD devrait corriger ce bug côté
amd-ucode, mais selon des informations non confirmées, une correction viaamd-ucodepourrait être difficile sur Zen 3 - L’espoir réaliste est donc que
glibcdésactive FSRM si nécessaire - Côté glibc, un travail est en cours avec x86: Improve ERMS usage on Zen3
Code de reproduction et ressources associées
- Xuanwo/when-i-find-rust-is-slow : ensemble des extraits de code et scripts utilisés
- Std::fs::read slow? : signalement similaire dans la communauté Rust
- Terrible memcpy performance on Zen 3 when using rep movsb : problème de performance
rep movsbsur Zen 3 signalé à la glibc Ubuntu - binding/python: rust std fs is slower than python fs : issue liée à la liaison Python d’OpenDAL
1 commentaires
Commentaires sur Hacker News
Il existe même deux flags de fonctionnalités CPU dédiés indiquant que
REP STOS/MOVest rapide et peut être utilisé comme courte séquence d’instructions pourmemset/memcpyCela fait des décennies qu’on souffre à devoir réécrire à la main les routines d’optimisation à chaque nouvelle génération de CPU, et on en est encore là ; on se dit que ce genre de cas devrait déjà figurer dans la suite de tests de timing des fabricants de CPU
Il est possible qu’il y ait eu un problème avec
rep movsrapide sur des pages alignées, ou qu’il ait été désactivé à cause d’une vulnérabilité à une attaqueJe ne vois pas bien à quoi devrait ressembler la correction, ni s’il faut quelque chose comme une vérification à l’exécution
S’il existe une implémentation « logicielle » plus rapide, je me demande pourquoi
REP MOVSne fait pas au moins la même chose au niveau du microcodeLe bug glibc lié est ici. En revanche, celui-ci concerne Zen 4 : https://sourceware.org/bugzilla/show_bug.cgi?id=30994
Au début, en lisant le billet, j’étais prêt à me moquer de l’auteur en pensant qu’il avait mal utilisé
std::fs, mais en réalité c’était un texte très plaisant, avec un terrier de débogage et un mystère qui se prolongeaientC’était bien écrit et très intéressant
Le point de départ est un peu déroutant. Il ne s’agissait pas de comparer du code Python pur à du code natif C/Rust, mais une méthode de lecture de fichier Python, qui est un wrapper Python au-dessus de code natif, à OpenDAL, qui est un autre wrapper autour de code natif
Qu’il y ait un écart de performances reste intéressant, mais le formuler comme « plus lent que Python » est assez étrange. Était-ce en partant de l’idée que la bibliothèque standard Python serait entièrement écrite en Python pur ? Au contraire, je m’attendrais à ce que les fonctions de la bibliothèque standard Python soient natives et individuellement très optimisées
Que la conclusion soit liée au fonctionnement du code natif n’avait rien de surprenant, mais la réponse précise était inattendue. C’est juste le point de départ qui m’a paru confus ; le texte lui-même était très intéressant
Et le titre « C is slower than Python with specified offset » se lit, pour un locuteur natif, comme « même avec un offset spécifié, C est plus lent que Python ». En réalité, c’était l’inverse : une fois le même offset utilisé en Python spécifié aussi en C, C devenait plus rapide
Qu’une opération aussi simple qu’une lecture de fichier soit plus lente dans la bibliothèque standard Rust que dans la bibliothèque standard Python, c’est surprenant. Même en sachant que ce type d’appel de bibliothèque standard Python est écrit en C, on s’attend quand même à ce qu’un appel de bibliothèque standard Rust soit à peu près du même ordre de vitesse
On supposerait donc en général soit une mauvaise utilisation, soit un comportement étrange de la bibliothèque standard Rust ; or ici ce n’était ni l’un ni l’autre, mais une falaise de performance liée à l’alignement des allocations sur un matériel donné
On s’attend à ce que la lecture de système de fichiers soit bien optimisée en Python, mais on pense la même chose pour Rust ; donc le fait que Rust ait été bien plus lent était surprenant, et le fait que cela dépende du matériel et de l’allocateur l’était plus encore
Si du code écrit en Python est rapide, pour moi Python est rapide. Que l’implémentation soit dans un autre langage ou pour une autre raison m’importe peu
Ce qui s’est passé dans le billet d’origine tient presque entièrement du hasard. Le code C de CPython ne respecte même pas systématiquement
const, et il y a beaucoup d’allocations mémoire dynamiques ainsi que d’appels auxiliaires/de confort. Même des opérations comme l’arithmétique font des allocations mémoire dynamiquesSi l’on a déjà travaillé avec CPython, on ne s’attend généralement pas à de bonnes performances. Quand on veut améliorer les performances, on cherche plutôt à contourner les mécanismes qu’il fournit
En outre, Python n’a pas de standard, donc à proprement parler il n’existe pas de bibliothèque standard, et la plupart des bibliothèques distribuées avec lui sont écrites en Python. Certaines sont écrites en C, mais même dans ce code C, une part non négligeable consiste en fait à transposer presque mécaniquement du code Python en C. Par exemple, l’implémentation de la recherche binaire en Python a d’abord été écrite en Python, puis traduite plus tard en C à l’aide de l’API C de Python
Ce à quoi on peut s’attendre, c’est surtout qu’un wrapper relativement léger soit placé sur les fonctionnalités qui se mappent simplement aux fonctions du système d’exploitation. Autrement dit, une lecture de fichier passe essentiellement directement par une interface système, donc le code de binding nécessaire ne devrait pas être important
Tout le monde s’en est rendu compte après avoir vu passer des dizaines d’articles du même genre
L’article lui-même est excellent et contient beaucoup d’informations intéressantes sur ce sujet.
Cela dit, la partie qui m’intéresse davantage et m’inquiète plus est la manière dont ce problème est signalé et consigné, ainsi que la façon dont la communication est gérée.
Le signalement se fait sur Discord, un environnement propriétaire, non indexé, difficile à rechercher, et qui n’est pas conservé. Les discussions ont lieu sur Discord et Telegram, et dans ce contexte, Telegram est peut-être encore pire.
Ce billet de blog et le dépôt GitHub sont les seules traces qu’il en reste. Si Xuanwo n’avait pas écrit ce billet, tout cela aurait disparu dans la timeline. C’est une situation assez intéressante.
Il existe très peu de messageries qui indexent et rendent consultables, par défaut, des journaux librement accessibles au public. Tous les serveurs IRC ne proposent pas de logs publics, et il en va de même pour les groupes Matrix. Je ne vois donc pas pourquoi les discussions qui s’y tiennent, elles, ne disparaîtraient pas dans la timeline.
Si des logs publics peuvent être proposés, ce n’est pas parce que la plateforme n’est pas propriétaire, mais parce qu’il existe une API qui autorise la journalisation. Telegram dispose aussi d’une telle API, et les logs consultables de notre groupe de discussion sont visibles ici : https://luoxu-web.vercel.app/#g=1264662201
Si l’indexation publique n’est pas possible, c’est principalement pour des raisons de confidentialité, pas parce que la plateforme est propriétaire.
Avant, on pouvait rechercher proprement tous les messages via DejaNews, puis plus tard via Google.
La communication importante de projets open source majeurs, comme la pile Internet/WWW et les outils et bibliothèques fondamentaux de programmation, devrait se faire sur des standards ouverts.
C’était la lecture la plus intéressante que j’ai eue cette semaine. Excellent récapitulatif.
La chose évidente à faire semble être d’envoyer un patch pour la méthode noyau
copy_user_generic.Si un CPU défectueux est détecté et qu’il provoque un bug qui ralentit l’alignement mémoire, il suffit d’utiliser une autre implémentation de copie mémoire.
Une correction acceptable pour des personnes sans expérience du noyau ne sera pas triviale. Plus important encore, il n’est pas clair non plus comment activer une éventuelle parade. Le mieux serait probablement de faire une mesure au démarrage, sinon il est difficile de savoir quels modèles et steppings sont touchés.
Une atténuation logicielle serait aussi complexe. Le noyau ne peut en effet pas utiliser les instructions vectorielles qu’il emploie normalement dans les chemins alternatifs quand ERMS n’est pas disponible.
jemallocétait l’allocateur par défaut de Rust jusqu’en 2018.https://internals.rust-lang.org/t/jemalloc-was-just-removed-...
Le passage « les développeurs Rust peuvent envisager de passer à
jemallocatorpour améliorer les performances » m’intrigue.Je ne sais pas si c’est un gain de performances presque gratuit pour tout le monde, ou s’il y a des points d’attention. Je me demande aussi si une base de code en C pourrait en profiter, et si c’est une performance qu’on laisse simplement sur la table aujourd’hui.
jemalloc, il y a un problème d’observabilité à cause deMADV_FREE.htopne montre alors plus correctement la mémoire réellement utilisée.https://github.com/jemalloc/jemalloc/issues/387#issuecomment...
https://gitlab.haskell.org/ghc/ghc/-/issues/17411
Apparemment,
jemallocappelle désormaisMADV_DONTNEED10 secondes aprèsMADV_FREE: https://github.com/JuliaLang/julia/issues/51086#issuecomment...Cela « corrige » donc le problème, mais introduit un délai déroutant entre le moment où la mémoire est libérée et celui où on peut l’observer dans
htop.Cela dit, d’après https://jemalloc.net/jemalloc.3.html, on peut supprimer ce délai en réglant
opt.muzzy_decay_ms = 0.Malgré cela, l’auteur de musl reste réservé à l’idée de faire de
jemallocle choix par défaut : https://www.openwall.com/lists/musl/2018/04/23/2En gros, il y aurait des problèmes de forte fragmentation, d’affaiblissement de l’ASLR et d’optimisations trop orientées vers la vitesse au détriment de l’usage mémoire. Le réglage ci-dessus peut en atténuer une partie, mais l’orientation générale — privilégier les performances ou l’usage mémoire — reste probablement un compromis important.
Ce ne sera pas forcément plus rapide dans absolument tous les cas, mais dans la grande majorité des cas, ça le sera. Rust utilisait d’ailleurs
jemallocpar défaut autrefois, mais cela a changé parce que certains trouvaient ce choix par défaut surprenant.Cela dépend fortement de la charge de travail, donc il faut faire du profiling et du benchmarking. Cela dit, les langages bas niveau comme C/C++/Rust devraient permettre de choisir ce type d’allocateurs.
Un point d’attention supplémentaire est la taille du binaire. Un allocateur personnalisé ajoute des octets à l’exécutable.
jemallocpar défaut, puis est revenu vers lemallocsystème vers 2018[0].Aujourd’hui, Rust a le trait
GlobalAllocet l’attribut#[global_allocator], donc une application peut utiliserjemalloccomme allocateur si elle le souhaite. Je ne sais pas vraiment si un utilisateur peut l’écraser via quelque chose commeLD_PRELOAD.jemallocn’est pas toujours le meilleur choix pour toutes les charges de travail et tous les cas d’usage. Les allocateurs système sont souvent loin d’être parfaits, mais au moins ils ont été largement testés comme allocateurs généralistes.[0] https://github.com/rust-lang/rust/issues/36963
jemallocpeut être un bon choix pour certaines applications, mais dans d’autres cas, un autre allocateur peut être plus rapide. Ou bien il peut être plus lent tout en correspondant mieux à d’autres objectifs, comme moins de mémoire sale, une meilleure observabilité ou certaines garanties de sécurité spécifiques.J’ai envoyé ça aux bonnes personnes.