22 points par GN⁺ 2025-06-24 | 3 commentaires | Partager sur WhatsApp
  • Les performances des pipes Unix implémentés sous Linux sont analysées via une optimisation progressive
  • La bande passante d’un premier programme de pipe très simple est mesurée à environ 3.5GiB/s, puis l’article montre comment l’améliorer de plus de 20x grâce au profiling et à des changements de syscalls
  • Il explique diverses techniques d’optimisation, notamment l’usage de syscalls zero-copy comme vmsplice et splice, afin de réduire les copies de données inutiles, ainsi que l’augmentation de la taille des pages
  • Avec l’utilisation de Huge Pages et une technique de boucle active (busy loop), les goulets d’étranglement sont résolus et un débit maximal de 62.5GiB/s est atteint
  • L’article apporte des enseignements sur des éléments importants en programmation serveur haute performance et en programmation kernel comme les pipes, la pagination, le coût de la synchronisation et le zero-copy

Vue d’ensemble et introduction

  • Cet article montre comment les pipes Unix sont implémentés sous Linux, en écrivant soi-même des programmes de test qui lisent et écrivent via un pipe, puis en optimisant progressivement leurs performances
  • Il commence avec un programme simple offrant environ 3.5GiB/s de bande passante, puis atteint au final un gain de performance d’environ 20x grâce à différentes optimisations
  • Chaque étape d’optimisation est décidée à partir des résultats de profiling obtenus avec l’outil perf, et le code source associé est disponible sur GitHub - pipes-speed-test
  • La réflexion est née en observant la vitesse de traitement de données via des pipes dans un programme FizzBuzz haute performance (36GiB/s)
  • Un niveau de base en langage C suffit pour suivre le contenu

Mesure des performances des pipes : première version lente

  • Un exemple d’exécution du programme FizzBuzz haute performance montre qu’il traite 36GiB de données par seconde via des pipes
  • FizzBuzz produit sa sortie par blocs de taille L2 cache (256KiB), afin de trouver un équilibre entre les accès mémoire et le surcoût d’IO
  • Le programme de test des performances des pipes écrit dans cet article fonctionne lui aussi par blocs répétés de 256KiB en lecture/écriture, en implémentant directement les deux extrémités read et write pour la mesure
  • write.cpp écrit en boucle le même buffer de 256KiB, tandis que read.cpp lit 10GiB puis s’arrête en affichant le débit
  • Résultat : la lecture/écriture via pipe atteint 3.7GiB/s, soit environ 10 fois moins que FizzBuzz

Goulets d’étranglement de l’écriture et structure interne

  • En traçant le call graph à l’exécution avec l’outil perf, on constate qu’environ la moitié du temps total est consommée dans la phase d’écriture dans le pipe, c’est-à-dire pipe_write
  • À l’intérieur de pipe_write, la majeure partie du temps est dépensée en copie et allocation de pages mémoire (copy_page_from_iter, __alloc_pages)
  • Sous Linux, un pipe est implémenté sous la forme d’un ring buffer, dont chaque entrée référence une page contenant les données réelles
  • La taille totale du buffer du pipe est fixe, et quand le pipe est plein, write bloque, tandis que lorsque le pipe est vide, read se bloque
  • Dans les structures C (pipe_inode_info, pipe_buffer), head et tail représentent respectivement les positions d’écriture et de lecture, avec les informations d’offset et de longueur de chaque page

Logique de lecture/écriture du pipe

  • pipe_write fonctionne globalement selon la séquence suivante
    • si le pipe est plein, il attend qu’un espace se libère
    • il remplit d’abord l’espace restant à la position head courante
    • s’il reste encore de la place, il alloue une nouvelle page, copie les données dans le buffer, puis met à jour head
  • Toutes les opérations sont protégées par des verrous, ce qui entraîne un surcoût de synchronisation
  • La lecture (read) suit la même logique en déplaçant tail et en libérant les pages lues
  • En pratique, cela implique deux copies coûteuses : de la mémoire utilisateur vers le kernel, puis du kernel vers l’espace utilisateur

Zero-copy : optimisation avec splice/vmsplice

  • Pour accélérer les IO, l’approche classique consiste à contourner le kernel (bypass) ou à minimiser les copies
  • Linux prend en charge les syscalls splice et vmsplice pour éviter les copies lors des transferts de données entre pipes et espace utilisateur
    • splice : déplacement de données entre un pipe et un file descriptor
    • vmsplice : déplacement de données entre la mémoire utilisateur et un pipe
  • Dans les deux cas, seules les références sont déplacées, sans mouvement réel des données
  • Par exemple, avec vmsplice, le buffer de 256KiB est divisé en deux, puis chaque moitié est envoyée alternativement dans le pipe selon un mécanisme de double buffering
  • En pratique, l’usage de vmsplice multiplie la vitesse par plus de 3 (environ 12.7GiB/s), et l’application de splice côté lecture la pousse ensuite à 32.8GiB/s

Goulets d’étranglement liés aux pages et usage des Huge Pages

  • L’analyse perf montre que le goulet d’étranglement de vmsplice se concentre sur le verrou du pipe (mutex_lock) et sur l’obtention des pages (iov_iter_get_pages)
  • iov_iter_get_pages convertit la mémoire utilisateur (adresse virtuelle) en pages physiques réelles afin d’en stocker les références dans le pipe
  • La pagination Linux n’utilise pas uniquement des pages de 4KiB : selon l’architecture, elle prend aussi en charge des tailles variées comme 2MiB (huge page)
  • En utilisant des Huge Pages (par exemple 2MiB), le surcoût de traduction des pages diminue fortement grâce à la réduction du travail sur les tables de pages et du nombre de références
  • Dans le programme, l’application des huge pages fait monter le débit maximal à 51.0GiB/s, soit environ 50 % de plus

Application d’une busy loop

  • Le goulet d’étranglement restant concerne la synchronisation, avec par exemple l’attente qu’un espace se libère dans le pipe (wait) et le réveil du lecteur (wake)
  • En utilisant l’option SPLICE_F_NONBLOCK puis en relançant l’appel dans une busy loop lorsqu’un EAGAIN survient, on supprime le surcoût d’ordonnancement du kernel
  • Cette technique porte le débit maximal à 62.5GiB/s, soit 25 % de mieux
  • Une busy loop consomme 100 % des ressources CPU, mais c’est un schéma courant sur les serveurs haute performance

Conclusion et autres points

  • L’article explique, à l’aide de perf et de l’analyse du code source Linux, comment améliorer spectaculairement les performances d’un pipe étape par étape
  • Il permet d’explorer avec des exemples concrets plusieurs sujets majeurs de la programmation haute performance : pipes, splice, pagination, zero-copy, coût de la synchronisation
  • Dans le code réel, d’autres réglages de performance sont aussi appliqués, comme l’allocation des buffers sur des pages différentes pour réduire la contention sur le refcount
  • Les tests sont exécutés en fixant chaque processus sur un cœur distinct avec taskset
  • Par conception, la famille splice peut être risquée et fait l’objet de débats de longue date chez certains développeurs kernel

3 commentaires

 
iolothebard 2025-06-27

Waouh ! C’est sympa ! (Je ne comprends absolument pas de quoi ça parle…)

 
doolayer 2025-06-26

|

 
GN⁺ 2025-06-24
Avis Hacker News
  • Je n’ai jamais oublié l’expérience de portage vers Windows d’une application basée sur les pipes Linux. Comme c’était du standard POSIX, je pensais que les performances ne seraient pas très différentes, mais c’était incroyablement lent. Quand on attendait l’établissement d’une connexion par pipe, on arrivait presque à faire geler tout Windows. Quelques années plus tard, quand j’ai réimplémenté la même chose en C# sur Win10, c’était un peu mieux, mais l’écart de performance restait une vraie honte.

    • Il me semble que Windows a ajouté les sockets AF_UNIX ces dernières années. Je me demande lesquelles sont les plus performantes par rapport aux pipes Win32 ; j’imagine que ce serait plutôt AF_UNIX.

    • Quand tu disais « les performances étaient désastreuses », tu parlais des E/S une fois le pipe déjà connecté, ou bien de la phase précédant la connexion ? Si c’était après connexion, ce serait surprenant, mais si le problème venait des cycles répétés de connexion/déconnexion, je peux admettre que l’OS ne les ait pas optimisés, puisqu’en pratique on en a rarement besoin. Selon le cas d’usage, je le comprendrais différemment.

    • D’après ce que j’ai vérifié récemment, sous Windows, les performances du TCP local sont bien meilleures que celles des pipes.

    • POSIX ne définit que le comportement, pas les performances ; il faut se rappeler que chaque plateforme et chaque OS ont leurs propres particularités de performance.

    • J’ai eu autrefois l’expérience inverse. Ce n’était pas avec des pipes, mais lorsqu’une application PHP sous Linux communiquait avec une API SOAP en .NET, j’avais le souvenir que l’implémentation .NET répondait plus vite.

  • Pour info, il existe diverses approches comme readv() / writev(), splice(), sendfile(), funopen(), io_buffer(), etc. splice() est excellent pour transférer de gros volumes de données en zero-copy entre pipes et sockets UNIX, mais c’est spécifique à Linux. Pour les transferts, splice() est la méthode la plus rapide car elle évite les allocations mémoire en espace utilisateur, la gestion de buffers supplémentaires, memcpy() et le parcours des iovec. Il y a aussi une demande de confirmation sur les BSD quant au fait que readv()/writev() soit vraiment optimal pour les pipes. Quoi qu’il en soit, cet article est jugé très impressionnant.

    • sendfile() offre des performances très élevées en zero-copy pour les transferts fichier→socket, et fonctionne à la fois sous Linux et BSD, mais ne prend en charge que fichier→socket. sendmsg() ne peut pas être utilisé avec des pipes ordinaires ; il est destiné aux sockets de domaine UNIX / INET / autres sockets. À noter que sous Linux, sendfile est implémenté en interne via splice, ce qui m’a d’ailleurs permis de l’utiliser en pratique pour des transferts fichier→périphérique bloc.

    • splice() est ce qu’il y a de mieux sous Linux pour les transferts massifs ultra-rapides entre pipes, mais avec un usage correct de io_uring, on peut espérer des performances comparables, voire supérieures.

    • En pratique, la mémoire partagée du type shm_open avec transmission de descripteurs de fichier est encore plus rapide, et totalement portable.

  • Quelqu’un indique qu’une discussion animée sur cet article avait déjà eu lieu sur HN, avec les liens https://news.ycombinator.com/item?id=31592934 (200 commentaires) et https://news.ycombinator.com/item?id=37782493 (105 commentaires).

  • Très bel article, et c’est aussi un vrai plaisir de le voir revenir régulièrement dans les discussions.

    • Correction d’une coquille : comes → comes up.
  • Déception de voir qu’il n’y a encore aucun commentaire, et envie d’utiliser davantage splice, mais les questions de sécurité et de compatibilité ABI mentionnées à la fin du texte inquiètent. La personne se demande si splice continuera d’être maintenu à l’avenir, ainsi que la difficulté qu’il y aurait à patcher les pipes par défaut pour qu’ils utilisent toujours splice afin d’améliorer les performances.

  • Question sur l’existence, dans les versions récentes de Linux, de quelque chose de comparable aux Doors de SunOS, dans le cadre d’une application embarquée nécessitant des échanges de petites données avec une latence extrêmement sensible, et cherchant une technique meilleure que AF_UNIX.

    • La mémoire partagée est la plus rapide en termes de latence, mais il faut réveiller les tâches, généralement via futex. Google développait l’appel système FUTEX_SWAP, qui devait permettre un handover direct d’une tâche à une autre, mais je ne sais pas ce qu’il en est devenu.

    • Demande d’explication, car « Doors » est un mot trop générique pour être facile à rechercher.

    • Demande d’informations complémentaires sur ce qui pose problème avec AF_UNIX aujourd’hui : manque de fonctionnalités, latence plus élevée que souhaité, ou inadéquation du modèle API socket serveur/client.

  • Ajout d’une information concise : l’article a été écrit en 2022.