1 points par GN⁺ 2024-08-27 | 1 commentaires | Partager sur WhatsApp
  • Écrire des données dans un pipe Linux avec write s’est révélé bien plus lent qu’une simple écriture en mémoire, avec dans l’environnement de test un débit d’environ 17GB/s pour le pipe contre 167GB/s pour l’écriture dans un tampon mémoire
  • Le goulot d’étranglement ne s’explique pas par une seule copie de données : dans pipe_write, le coût de l’allocation de pages, des verrous et des routines de copie du noyau s’additionne
  • vmsplice relie un tampon de l’espace utilisateur au pipe sans le copier dans le noyau, ce qui contourne des chemins coûteux comme __alloc_pages, _raw_spin_lock_irq et copy_user_enhanced_fast_string
  • Dans le cas du débit Fizz Buzz, la solution utilisant vmsplice a atteint 60.8GiB/s sur un seul cœur et 208.3GiB/s sur plusieurs cœurs, tandis qu’un exemple vmsplice d’une autre expérience a enregistré 210GB/s
  • À cause de corrections apportées ensuite, l’interprétation d’une pénalité liée à l’absence de SIMD n’est plus vraiment fiable, et la communication inter-processus ne peut pas se limiter au cache L1, ce qui rend irréaliste l’idée d’obtenir 167GB/s comme débit réel d’un pipe

Point de départ : l’énorme écart créé par vmsplice

  • Certains programmes utilisent l’appel système vmsplice pour déplacer des données plus vite via des pipes
  • Dans le concours de débit Fizz Buzz sur Code Golf StackExchange, les solutions se séparent nettement en deux groupes
    • Les solutions sans vmsplice restent à quelques GiB/s, et celle de neil atteint 8.4GiB/s
    • Les solutions avec vmsplice montent à 15.5GiB/s pour tkluck, 60.8GiB/s pour ais523 et jusqu’à 208.3GiB/s en multi-cœur pour david
  • Le simple fait de réduire les copies entre l’espace noyau et l’espace utilisateur ne suffit pas à expliquer un écart d’environ 7× sur un seul cœur
  • Dans des tests séparés, la solution d’ais523 a atteint 96.4GiB/s, et celle de david 277GB/s sur 7 cœurs, soit environ 40GB/s par cœur

Référence : l’écriture mémoire en espace utilisateur

  • Un programme Rust qui recopie en boucle un tampon de 32KiB en espace utilisateur, sans appel système, atteint 167GB/s dans l’environnement de test
  • Cette valeur est considérée comme cohérente avec la vitesse d’écriture dans le cache L1 du CPU utilisé
    • La machine de test utilise un Ryzen 9 7950X3D, de la DDR5 6000T/s, Debian 12 et Linux 6.1.0-18-amd64
    • Les options d’atténuation CPU ont été désactivées avec mitigations=off
  • Le profilage avec ftrace montre que l’essentiel du temps est passé dans __memset_avx512_unaligned_erms
  • Mais des corrections ultérieures limitent cette interprétation
    • L’instruction observée au point d’arrêt était rep stos, qui n’est pas une instruction AVX-512
    • Même en limitant l’exécution à AVX2 et SSE2, le débit reste à 167GB/s parce que rep stos est utilisé dans tous les cas

Coût réel d’une écriture dans un pipe avec write

  • Lorsqu’on écrit un tampon de même taille dans un pipe avec stdout.write() et qu’on le consomme avec pv >/dev/null, le débit tombe à 17GB/s
  • Le profilage montre que la majeure partie du temps est passée dans l’appel système write, dont 95% à l’intérieur de pipe_write
  • Dans pipe_write, plusieurs coûts s’additionnent : préparation des pages, verrous et copie
    • __alloc_pages : 36% du temps total, pour préparer de nouvelles pages mémoire destinées au pipe
    • __mutex_lock.constprop.0 : 25% du temps total, correspondant au coût de verrouillage lors de l’écriture dans le pipe
    • _raw_spin_lock_irq : 5% du temps total, également lié au verrouillage
    • copy_user_enhanced_fast_string : environ 20% du temps total, pour copier les données de l’espace utilisateur vers le noyau
  • Comme pv consomme les pages en les déplaçant vers /dev/null via splice, il devient difficile de réutiliser en boucle les mêmes quelques pages

Routine de copie du noyau et interprétation corrigée

  • En désassemblant copy_user_enhanced_fast_string, on voit que les grosses copies de tampons sont traitées avec l’instruction REP MOV
  • Cette fonction n’est pas écrite en C mais en assembleur du noyau Linux : il s’agit donc d’un chemin voulu, pas d’une optimisation manquée par le compilateur
  • Dans l’expérience initiale, appeler directement rep movsb depuis l’espace utilisateur donnait 80GB/s, ce qui avait conduit à l’idée que la routine de copie du noyau était environ deux fois plus lente
  • Des corrections ultérieures ont toutefois modifié les conditions du test
    • Deux tampons de 32KiB saturent le cache de données L1
    • Avec des tampons de 16KiB, les performances montent à 153GB/s
  • En conséquence, l’interprétation initiale selon laquelle l’absence d’instructions vectorielles dans la copie noyau entraînerait une forte pénalité n’est plus vraiment fiable
  • Il reste néanmoins que la surcharge de gestion mémoire pèse lourd dans l’écriture dans les pipes

Les chemins noyau évités par vmsplice

  • vmsplice transmet au pipe un tampon complet de l’espace utilisateur sans le recopier intégralement dans le noyau, ce qui réduit le coût d’utilisation des pipes
  • L’exemple ./write inclus dans pipes-speed-test de Francesco sert de cas minimal écrivant indéfiniment des 'X'
  • Cet exemple atteint 210GB/s, mais il renvoie toujours le même tampon à vmsplice, ce qui diffère d’un vrai travail de génération de données
    • Si le flux n’est pas un simple octet constant, il faut remplir le tampon avec de nouvelles données
    • Dans ce cas, la limite d’une simple écriture mémoire à 167GB/s redevient pertinente
  • Même sur le chemin vmsplice, __mutex_lock.constprop.0 représente encore 37% du temps
  • En revanche, les fonctions vues sur le chemin write comme __alloc_pages, _raw_spin_lock_irq et copy_user_enhanced_fast_string n’apparaissent pas
  • À la place, les chemins dominants deviennent add_to_pipe, import_iovec et iov_iter_get_pages2, ce qui montre que vmsplice évite bien les parties coûteuses de write

Conclusion restante et points de vigilance

  • D’après l’expérience, écrire dans un pipe Linux via write est environ 10 fois plus lent qu’une simple écriture mémoire
  • La conclusion initiale affirmait que le coût des verrous et de la sauvegarde/restauration du contexte SIMD dans l’écriture vers les pipes était élevé, et que splice et vmsplice contournaient cela
  • Après correction, la conclusion doit être lue de façon plus limitée
    • La surcharge de gestion mémoire du noyau reste un facteur important de la baisse de performances des pipes
    • L’idée selon laquelle l’absence d’instructions vectorielles constituerait une pénalité aussi forte est inexacte
    • Comme la communication inter-processus ne peut pas se faire uniquement dans le cache L1, atteindre 167GB/s sur un débit réel incluant aussi la lecture du pipe reste peu probable
  • Certaines erreurs importantes ont été corrigées et la fiabilité globale des chiffres peut être limitée ; il faut donc surtout les lire comme des ordres de grandeur
  • Pour augmenter le débit des pipes, il ne suffit pas de réduire le nombre d’appels système : il faut aussi examiner le chemin interne parcouru par write dans le noyau et la manière dont les tampons sont traités

1 commentaires

 
GN⁺ 2024-08-27
Avis sur Hacker News
  • Il existe un projet parallèle qui tente de s’attaquer à ce problème : https://lwn.net/Articles/976836/
    L’idée est de créer un appel système permettant d’obtenir un ring buffer pour tous les descripteurs de fichiers pris en charge. Cela inclurait aussi les pipes ; si les deux extrémités prennent en charge l’utilisation d’un ring buffer, elles pourraient mapper le même ring buffer pour faire des entrées/sorties sans copie, et dans certains cas ne faire aucun appel au noyau. Le projet cherche des contributeurs

    • Je ne suis pas sûr qu’une nouvelle fonctionnalité du noyau soit indispensable, au moins pour les usages en espace utilisateur. Il y a quelque temps, j’ai implémenté un ring buffer mono-producteur/mono-consommateur en espace utilisateur qui imitait assez fidèlement le comportement d’un pipe avec eventfd
      Quand le ring buffer est plein ou vide, il permet sleep/poll ; le reste du temps, il fonctionne sans verrou et sans surcoût d’appel système
    • Je me demande s’il est prévu un mécanisme de signalisation standard permettant aux deux extrémités d’un pipe d’indiquer à l’autre qu’elles prennent en charge les ring buffers. Ce serait nécessaire pour que libc puisse le gérer de façon transparente ; sinon, pour les pipes, je ne vois pas bien l’avantage par rapport à de la mémoire partagée avec synchronisation par futex
    • ringbuffer_wait() pourrait probablement aussi signaler l’état prêt en lecture via poll()
    • Je me demande si les interfaces de ring buffer existantes adopteront cela, ou si l’on va se retrouver dans une situation xkcd927. En tout cas, cela ressemble à une tentative intéressante
    • Le buffering existe pour une raison, et cette approche peut créer des modes de défaillance étranges et des vulnérabilités dans les scripts. Le point essentiel est que n’importe quel producteur de flux peut devenir plus lent qu’un consommateur donné
      Un simple hiccup ponctuel suffit à mettre tout le pipe en vrac s’il n’y a pas assez de buffering, et la taille de buffer nécessaire varie d’un système à l’autre
  • Si JMP n’est pas simplement RET, c’est à cause de l’option CONFIG_RETHUNK. Ce que montre le désassemblage objdump, c’est le résultat du remplacement de RET par JMP __x86_return_thunk
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/lib/ret...
    Les instructions NOP au début et à la fin de la fonction ne viennent pas de ftrace, mais des macros ASM_CLAC/ASM_STAC. Ces macros réservent de l’espace qui sera rempli à l’exécution par les instructions CLAC/STAC si X86_FEATURE_SMAP est détecté. Les deux instructions font 3 octets, soit le même nombre que les NOP
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/kernel/...

    • Des développeurs noyau capables de savoir tout cela tout en choisissant un pseudo jetable aussi taquin, il ne doit pas y en avoir beaucoup
  • Dire que les pipes Linux sont « lents », c’est comme dire qu’une Toyota Corolla est « lente ». Sauf cas d’usage extrême, ils sont suffisamment rapides
    Vous faites de la course automobile ? Et dans une discipline où la vitesse compte plus que la technique ? Dans ce cas, achetez une voiture plus rapide. Sinon, gardez votre Corolla

    • Ce n’est pas du code de projet qui ne sera exécuté que quelques milliards de fois au cours de sa durée de vie, mais du code utilisé fréquemment sur des millions à des milliards d’ordinateurs
      Donc même un gain d’efficacité minuscule justifie économiquement de passer beaucoup de temps à l’optimiser
    • Dans le cas de l’auteur, le pipe qualifié de lent transfère en réalité les données à 17 Go/s, soit plus de 130 Gbit/s
      J’utilise des pipes depuis plus de dix ans pour toutes sortes d’usages et je n’ai jamais été bloqué par leur débit ; la plupart du temps, le goulot d’étranglement était probablement des outils comme tar, gzip, find, grep ou nc. Bien sûr, ces outils sont eux aussi plutôt rapides compte tenu de ce qu’ils font
    • J’ai un projet qui utilise un SDK propriétaire pour décoder de la vidéo brute. Les données décodées sortent en RGBA pur, puis FFMpeg les lit via un pipe pour les réencoder dans un codec standard
      FFMpeg ne peut pas inclure le SDK Non-Free dans ses sources, et stocker du RGBA pur dans un fichier est absurdement irréaliste. Le pipe est donc la seule solution, et il y a une raison légitime d’avoir besoin de pipes à haut débit
    • Rendre quelque chose d’omniprésent plus rapide de quelques pourcents est un investissement tout à fait valable. Même si chaque tâche individuelle ne s’accélère pas beaucoup, à l’échelle mondiale cela représente d’énormes économies d’énergie et de temps
    • Parfois, une Corolla plus rapide est vraiment la meilleure réponse
      https://www.toyota.com/grcorolla/
      Ces voitures ont une ingénierie et des performances impressionnantes, et elles tiennent aussi du hack pour contourner les règles qui rendaient difficile l’importation sur le marché américain de la GR Yaris initialement prévue. Je pense qu’il y a assez de contexte en matière d’ingénierie, de performance, de hack et de marché pour que les gens de HN l’apprécient au second degré. En plus, le patron de l’entreprise continue de la conduire et de la piloter lui-même
  • Indépendamment du cœur de l’article, sur les CPU modernes, rep movsb est aussi rapide que les versions vectorisées les plus rapides. C’est parce que le CPU sait l’accélérer.
    Le nom de la fonction du noyau copy_user_enhanced_fast_string le suggère aussi. Les fonctionnalités CPU concernées sont ERMS (Enhanced Repeat Move String, qui accélère rep movsb au-delà d’une certaine longueur) et FSRM (Fast Short Repeat Move String, qui accélère aussi les copies courtes).

    • Ce n’est pas toute l’histoire. rep movsb est rapide jusqu’à un certain seuil, mais au-delà, les écritures classiques ou les écritures non temporelles (non-temporal stores) sont plus rapides.
      Tous les seuils sont décrits dans https://codebrowser.dev/glibc/glibc/sysdeps/x86_64/multiarch....
      Et ces valeurs ne sont pas figées : Noah Goldstein les met encore à jour chaque année.
    • Il est aussi intéressant de noter que Linux a modifié à plusieurs reprises, depuis le noyau 6.1 utilisé dans l’article, sa manière d’utiliser ERMS et FSRM pour les copies sur x86. Pour référence, sur ma machine qui dispose à la fois de FSRM et d’ERMS — étonnamment, le premier n’implique pas le second — Linux 6.8 atteint 17 Go/s avec un simple pipe et un tampon de 32 Kio.
    • Pour les memcpy courts, j’attends encore que rep movsb et rep stosb deviennent assez rapides pour pouvoir supprimer les simples versions en boucle C.
    • Dans ce cas, je me demande quand les compilateurs C commenceront aussi à inliner les memcpy() de longueur variable, comme ils le font pour les memcpy de longueur fixe.
  • Il y a un point sur AVX512 que je n’ai pas vu dans l’article : en plus du surcoût de xsave/xrstor, AVX512 consomme beaucoup d’énergie et déclenche la mise à l’échelle de la fréquence CPU. Pour les détails et pour voir à quel point cela peut devenir subtil, voir [1], [2].
    [1] https://www.intel.com/content/dam/www/central-libraries/us/e...
    [2] https://www.intel.com/content/www/us/en/developer/articles/t...

    • Cela ne concerne que certains modèles de CPU Intel.
  • Presque toutes les formes de communication inter-processus sont « lentes ». C’est un choix : payer un coût en performance pour la sûreté.

    • Il n’est pas nécessaire de payer un coût aussi élevé. Les pipes offrent très peu de choses, donc leur coût devrait être très faible.
      En particulier, il n’y a pas beaucoup de raisons pour que la communication inter-processus la plus rapide soit plus lente qu’un long appel de fonction.
    • Les pipes n’existent pas pour la sûreté, mais comme une optimisation permettant de faire passer des données entre des programmes existants.
  • Je subis encore le hug of death de Hacker News. Grâce au cache de pages WordPress, c’est mieux que la dernière fois, mais le chargement des pages peut tout de même prendre quelques secondes ; merci de votre compréhension.

  • Je n’ai jamais vraiment compris pourquoi splice devait être aussi lent. L’article pointe l’allocation de tampons et l’utilisation d’instructions scalaires comme raisons de sa lenteur par rapport à vmsplice, mais je ne vois pas pourquoi cela serait nécessaire.
    Pourquoi splice ne pourrait-il pas simplement être réimplémenté comme vmsplice ? Il y a sûrement une bonne raison, mais j’ai dû la manquer.

  • Il serait aussi intéressant de voir une version utilisant io_uring. On pourrait partager à l’avance le tampon avec le noyau pour éviter certaines copies, et peut-être aussi éviter le surcoût des appels système. Cela dit, ici, ce dernier semble presque négligeable.

  • C’est une affirmation assez audacieuse pour un blog qui met environ 20 secondes à charger.

    • Comme cet article est monté tout en haut de Hacker News, il faut probablement être un peu indulgent.
      L’article lui-même semble excellent, et il y a beaucoup à apprendre sur ce qui se passe en interne.