Baisse des performances des pipes Linux
(qsantos.fr)- Écrire des données dans un pipe Linux avec
writes’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 vmsplicerelie 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_irqetcopy_user_enhanced_fast_string- Dans le cas du débit Fizz Buzz, la solution utilisant
vmsplicea atteint 60.8GiB/s sur un seul cœur et 208.3GiB/s sur plusieurs cœurs, tandis qu’un exemplevmspliced’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
vmsplicepour 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
vmsplicerestent à quelques GiB/s, et celle de neil atteint 8.4GiB/s - Les solutions avec
vmsplicemontent à 15.5GiB/s pour tkluck, 60.8GiB/s pour ais523 et jusqu’à 208.3GiB/s en multi-cœur pour david
- Les solutions sans
- 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
ftracemontre 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 stosest utilisé dans tous les cas
- L’instruction observée au point d’arrêt était
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 avecpv >/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 depipe_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 verrouillagecopy_user_enhanced_fast_string: environ 20% du temps total, pour copier les données de l’espace utilisateur vers le noyau
- Comme
pvconsomme les pages en les déplaçant vers/dev/nullviasplice, 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’instructionREP 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 movsbdepuis 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
vmsplicetransmet 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
./writeinclus danspipes-speed-testde 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.0représente encore 37% du temps - En revanche, les fonctions vues sur le chemin
writecomme__alloc_pages,_raw_spin_lock_irqetcopy_user_enhanced_fast_stringn’apparaissent pas - À la place, les chemins dominants deviennent
add_to_pipe,import_iovecetiov_iter_get_pages2, ce qui montre quevmspliceévite bien les parties coûteuses dewrite
Conclusion restante et points de vigilance
- D’après l’expérience, écrire dans un pipe Linux via
writeest 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
spliceetvmsplicecontournaient 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
writedans le noyau et la manière dont les tampons sont traités
1 commentaires
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
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
ringbuffer_wait()pourrait probablement aussi signaler l’état prêt en lecture viapoll()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
JMPn’est pas simplementRET, c’est à cause de l’option CONFIG_RETHUNK. Ce que montre le désassemblageobjdump, c’est le résultat du remplacement deRETparJMP __x86_return_thunkhttps://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/...
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
Donc même un gain d’efficacité minuscule justifie économiquement de passer beaucoup de temps à l’optimiser
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
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
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 movsbest 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_stringle suggère aussi. Les fonctionnalités CPU concernées sont ERMS (Enhanced Repeat Move String, qui accélèrerep movsbau-delà d’une certaine longueur) et FSRM (Fast Short Repeat Move String, qui accélère aussi les copies courtes).rep movsbest 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.
memcpycourts, j’attends encore querep movsbetrep stosbdeviennent assez rapides pour pouvoir supprimer les simples versions en boucle C.memcpy()de longueur variable, comme ils le font pour lesmemcpyde 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...
Presque toutes les formes de communication inter-processus sont « lentes ». C’est un choix : payer un coût en performance pour la sûreté.
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.
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
splicedevait ê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
splicene pourrait-il pas simplement être réimplémenté commevmsplice? Il y a sûrement une bonne raison, mais j’ai dû la manquer.vmsplicene fonctionne pas avec tous les types de descripteurs de fichiers.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.
L’article lui-même semble excellent, et il y a beaucoup à apprendre sur ce qui se passe en interne.