2 points par GN⁺ 2023-11-30 | 1 commentaires | Partager sur WhatsApp
  • 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 le std::fs de 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, eBPF et perf, la différence s’est révélée liée à l’offset du tampon de destination du syscall read à l’intérieur de la page, avec une dégradation de performances reproductible près de 0x10
  • 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 movsb dans le _copy_to_iter du noyau
  • Python n’est pas intrinsèquement plus rapide : le résultat venait d’un bug CPU lié à FSRM/rep movsb sur AMD Zen 3, combiné par hasard à des offsets mémoire particuliers ; l’amélioration observée avec jemalloc venait 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 ms
    • python-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 fs d’OpenDAL en Rust, le résultat restait plus lent que la lecture intégrée de Python
    • rust-opendal-fs-read : moyenne de 23.8 ms
    • python-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 fs d’OpenDAL utilise std::fs de Rust ; une implémentation distincte basée directement sur std::fs a 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::fs
    • rust-std-fs-read : moyenne de 23.1 ms
    • python-fs-read : moyenne de 15.2 ms
    • La lecture intégrée de Python était mesurée 1.52 fois plus rapide que std::fs de Rust

Les syscalls et mmap vus avec strace

  • L’analyse avec strace a montré que Rust comme Python utilisaient mmap pour allouer de gros tampons
  • L’exécution de Rust std::fs suivait le flux consistant à ouvrir /tmp/file, lire 64 MiB en une fois, appeler read pour vérifier l’EOF, puis fermer le fichier
  • La lecture intégrée de Python exécutait davantage de syscalls, comme newfstatat, ioctl et lseek, 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 anonyme
    • 67112960 correspond à 64 MiB plus 4 KiB
    • MAP_ANONYMOUS désigne une allocation mémoire sans lien avec un fichier
  • Le build par défaut de Rust pour x86_64-unknown-linux-gnu utilise le malloc de glibc, et glibc peut utiliser mmap pour 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 Python
    • rust-std-fs-read-with-jemalloc : moyenne de 9.7 ms
    • python-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, mmap ou 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, pymalloc ou mimalloc seraient intrinsèquement plus rapides que glibc 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 de 0x10 depuis l’adresse de départ du mmap
    • rust-std-fs-read-with-jemalloc : lecture à un offset de 0x740 depuis l’adresse de départ du mmap
  • La zone problématique a été ramenée à la plage 0x00..0x10 dans la page, et le même problème peut être reproduit avec jemalloc

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=off du noyau Linux ne changeait pas le résultat
    • Passer Transparent Hugepage à always, madvise ou never modifiait les valeurs absolues, mais pas le ratio relatif
    • Fixer l’exécution sur un cœur CPU précis avec core_affinity donnait le même résultat
  • Les mesures de latence du syscall read via eBPF montraient elles aussi un Rust plus lent
    • Python read file : 8,134,049 ns
    • Rust std::fs read file : 24,636,975 ns
  • 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 Python
    • c-fs-read : moyenne de 23.8 ms
    • python-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 de 0x10 depuis l’adresse renvoyée par mmap
    • Python : read à un offset de 0x30 depuis l’adresse renvoyée par mmap
  • 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-read et sa version avec offset sur un AMD Ryzen 9 5900HX, puis les a analysés avec perf
  • Selon la présence ou non de l’offset, les valeurs de L1-dcache-prefetches et L1-dcache-loads variaient fortement
    • Sans offset : L1-dcache-loads d’environ 127,845,213, L1-dcache-prefetches d’environ 1,843,493
    • Avec offset : L1-dcache-loads d’environ 13,965,813, L1-dcache-prefetches d’environ 395,578
  • 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 était rep 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 movsb se 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 movsb atteint 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 movsb et rep movsd
  • FSRM est une fonctionnalité apparue chez Intel puis introduite aussi chez AMD, et sur les CPU qui déclarent la prendre en charge, glibc l’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 via amd-ucode pourrait être difficile sur Zen 3
  • L’espoir réaliste est donc que glibc dé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

1 commentaires

 
GN⁺ 2023-11-30
Commentaires sur Hacker News
  • Il existe même deux flags de fonctionnalités CPU dédiés indiquant que REP STOS/MOV est rapide et peut être utilisé comme courte séquence d’instructions pour memset/memcpy
    Cela 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

    • C’est purement spéculatif, mais cela pourrait aussi venir de l’impact d’une correction de bug introduite via une mise à jour de microcode à la dernière minute ou après la sortie
      Il est possible qu’il y ait eu un problème avec rep movs rapide sur des pages alignées, ou qu’il ait été désactivé à cause d’une vulnérabilité à une attaque
    • Si j’ai bien compris, je me demande si cela signifie qu’il faut produire deux exécutables pour certaines builds au moment de la compilation, ou bien s’il faut compiler sur un matériel spécifique
      Je ne vois pas bien à quoi devrait ressembler la correction, ni s’il faut quelque chose comme une vérification à l’exécution
    • On a facilement tendance à penser que le fabricant du CPU connaît le mieux son propre CPU
      S’il existe une implémentation « logicielle » plus rapide, je me demande pourquoi REP MOVS ne fait pas au moins la même chose au niveau du microcode
  • Le 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 prolongeaient
    C’était bien écrit et très intéressant

    • C’était vraiment un très bon article. La méthode de débogage consistant à créer des programmes de test et à retirer les couches une par une était intelligente, la conclusion était intéressante et inattendue, et le texte était clair donc facile à suivre
  • 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

    • Je ne vois même pas très bien en quoi c’est déroutant
      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
    • On reproche à Python d’être un langage lent quand il est lent, mais quand il est rapide on refuse de lui en attribuer le mérite au motif que « ce n’est pas vraiment Python »
      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
    • Je ne vois pas pourquoi on devrait s’attendre à ce que ce soit « individuellement très optimisé »
      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 dynamiques
      Si 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
    • Merci pour la remarque. J’ai corrigé le titre
    • Le point de départ, c’est que si l’on écrit une formule du type « Python est plus rapide que Rust », on obtient des pages vues même si ce n’est pas vrai
      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.

    • Le fait que ce soit une plateforme propriétaire est vrai, et ce n’est pas une bonne chose. En revanche, le reproche selon lequel elle ne serait ni indexée ni consultable me paraît difficile à accepter.
      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.
    • C’est précisément pour cela que, chaque fois que je regrette la disparition d’USENET, je n’accepte pas la réponse « maintenant, il y a Discord ».
      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.

    • Ce n’est pas si évident. Si cela peut être corrigé par microcode, il semble préférable que les gens utilisent un microcode mis à jour plutôt que d’éparpiller dans le noyau du code de correction pour un problème qui, au fond, peut être corrigé par logiciel.
      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.
    • Ce n’est pas une correction triviale. AMD doit comprendre pourquoi l’aliasing se casse près des adresses alignées sur une page, donc il est probable que la correction soit côté microcode.
      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 à jemallocator pour 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.

    • Il faut savoir qu’avec jemalloc, il y a un problème d’observabilité à cause de MADV_FREE. htop ne 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, jemalloc appelle désormais MADV_DONTNEED 10 secondes après MADV_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 jemalloc le choix par défaut : https://www.openwall.com/lists/musl/2018/04/23/2
      En 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.
    • À mon avis, c’est quasiment une performance gratuite qu’on laisse de côté. Le coût, c’est une légère augmentation de la taille du binaire.
      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 jemalloc par défaut autrefois, mais cela a changé parce que certains trouvaient ce choix par défaut surprenant.
    • Passer à un allocateur non par défaut n’améliore pas toujours les performances.
      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.
    • Rust utilisait autrefois jemalloc par défaut, puis est revenu vers le malloc système vers 2018[0].
      Aujourd’hui, Rust a le trait GlobalAlloc et l’attribut #[global_allocator], donc une application peut utiliser jemalloc comme allocateur si elle le souhaite. Je ne sais pas vraiment si un utilisateur peut l’écraser via quelque chose comme LD_PRELOAD.
      jemalloc n’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
    • Les performances ne sont pas une échelle unidimensionnelle où un programme passe de « lent » à « rapide ». Il y a toujours d’autres facteurs en jeu.
      jemalloc peut ê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.

    • Tu veux dire que tu l’as envoyé à quelqu’un côté AMD ?