22 Punkte von GN⁺ 2025-06-24 | 3 Kommentare | Auf WhatsApp teilen
  • Analysiert wird die Leistung von in Linux implementierten Unix-Pipes anhand schrittweiser Optimierungen
  • Die Bandbreite eines anfänglich einfachen Pipe-Programms wird mit etwa 3.5GiB/s gemessen; gezeigt wird, wie sie durch Profiling und Änderungen an Systemaufrufen um mehr als das 20-Fache gesteigert wird
  • Es werden verschiedene Optimierungstechniken erläutert, darunter die Nutzung von Zero-Copy-Systemaufrufen wie vmsplice und splice, um unnötige Datenkopien zu vermeiden, sowie die Vergrößerung der Seitengröße
  • Durch den Einsatz von Huge Pages und der Busy-Loop-Technik werden Engpässe beseitigt und eine maximale Verarbeitungsgeschwindigkeit von 62.5GiB/s erreicht
  • Der Artikel liefert Einblicke in wichtige Faktoren für High-Performance-Server und Kernel-Programmierung wie Pipes, Paging, Synchronisierungskosten und Zero-Copy

Überblick und Einführung

  • Dieser Artikel behandelt, wie Unix-Pipes unter Linux implementiert sind, und verfolgt, wie die Leistung schrittweise optimiert wird, während direkt Testprogramme zum Lesen und Schreiben über Pipes erstellt werden
  • Zu Beginn steht ein einfaches Programm mit einer Bandbreite von ungefähr 3.5GiB/s, das durch verschiedene Optimierungen schließlich eine rund 20-fache Leistungssteigerung erreicht
  • Die Optimierungen in den einzelnen Schritten werden auf Basis der Profiling-Ergebnisse mit dem Tool perf entschieden; der zugehörige Quellcode ist unter GitHub - pipes-speed-test veröffentlicht
  • Auslöser war die Beobachtung der Datenverarbeitungsgeschwindigkeit über Pipes in einem High-Performance-FizzBuzz-Programm (36GiB/s)
  • Grundlegende C-Kenntnisse reichen aus, um dem Inhalt zu folgen

Messung der Pipe-Leistung: die erste langsame Version

  • Am Beispiel des High-Performance-FizzBuzz-Programms lässt sich sehen, dass über Pipes 36GiB Daten pro Sekunde verarbeitet werden können
  • FizzBuzz gibt in Blöcken der Größe des L2-Caches (256KiB) aus und hält so das Gleichgewicht zwischen Speicherzugriffen und IO-Overhead
  • Das in diesem Artikel geschriebene Pipe-Leistungstestprogramm gibt ebenfalls wiederholt in 256KiB-Blöcken aus bzw. liest sie ein; zur Messung werden beide Enden, read und write, selbst implementiert
  • write.cpp schreibt wiederholt denselben 256KiB-Puffer, read.cpp liest 10GiB und beendet sich anschließend mit Ausgabe des Durchsatzes
  • Im Test erreichen read und write über eine Pipe 3.7GiB/s und sind damit im Vergleich zu FizzBuzz etwa zehnmal langsamer

Engpässe beim Schreiben und interne Struktur

  • Mit dem Tool perf zeigt die Verfolgung des Call-Graphs beim Programmstart, dass ungefähr die Hälfte der gesamten Laufzeit im Schritt des Pipe-Schreibens, also in pipe_write, verbraucht wird
  • Innerhalb von pipe_write entfällt der Großteil der Zeit auf das Kopieren und Allozieren von Speicherseiten (copy_page_from_iter, __alloc_pages)
  • Linux-Pipes sind als Ringpuffer implementiert, wobei jeder Eintrag auf eine Seite verweist, auf der die eigentlichen Daten gespeichert sind
  • Die gesamte Puffergröße der Pipe ist fest; wenn die Pipe voll ist, blockiert write, und wenn sie leer ist, blockiert read
  • In den C-Strukturen (pipe_inode_info, pipe_buffer) kennzeichnen head und tail jeweils die Schreib- und Leseposition und enthalten Offsets und Längeninformationen der einzelnen Seiten

Lese-/Schreiblogik der Pipe

  • pipe_write arbeitet in der folgenden Reihenfolge
    • Wenn die Pipe voll ist, wird gewartet, bis wieder Platz verfügbar ist
    • Freier Platz an der aktuellen head-Position wird zuerst aufgefüllt
    • Wenn darüber hinaus Platz vorhanden ist, wird eine neue Seite allokiert, Daten werden in den Puffer kopiert und head wird aktualisiert
  • Alle Operationen sind durch Locks geschützt, wodurch Synchronisierungs-Overhead entsteht
  • Das Lesen (read) folgt derselben Struktur, verschiebt dabei tail und gibt gelesene Seiten wieder frei
  • Im Kern werden Daten zweimal kopiert: aus dem Userspace in den Kernel und vom Kernel zurück in den Userspace, was erheblichen Overhead verursacht

Zero-Copy: Optimierung mit splice/vmsplice

  • Ein allgemeiner Ansatz für schnelles IO besteht darin, den Kernel zu umgehen oder Kopiervorgänge zu minimieren
  • Linux unterstützt mit den Systemaufrufen splice und vmsplice, dass beim Datentransfer zwischen Pipe und Userspace auf Kopien verzichtet werden kann
    • splice: Datentransfer zwischen Pipe und Dateideskriptor
    • vmsplice: Datentransfer zwischen Userspace-Speicher und Pipe
  • Beide Systemaufrufe können nur Referenzen verschieben, ohne die Daten selbst zu bewegen
  • Beispielsweise wird bei der Nutzung von vmsplice ein 256KiB-Puffer halbiert und per Double Buffering abwechselnd mit jeder Hälfte in die Pipe vmsplicet
  • Tatsächlich verbessert vmsplice die Geschwindigkeit um mehr als das Dreifache (auf etwa 12.7GiB/s), und mit splice auf der Leseseite steigt sie weiter auf 32.8GiB/s

Seitenbezogene Engpässe und Einsatz von Huge Pages

  • Die perf-Analyse zeigt, dass sich die Engpässe von vmsplice auf den Pipe-Lock (mutex_lock) und das Abrufen von Seiten (iov_iter_get_pages) konzentrieren
  • iov_iter_get_pages wandelt Userspace-Speicher (virtuelle Adressen) in tatsächliche physische Seiten um und speichert deren Referenzen in der Pipe
  • Das Paging in Linux verwendet nicht nur 4KiB-Seiten; je nach Architektur werden auch andere Größen wie 2MiB (Huge Pages) unterstützt
  • Mit Huge Pages (z. B. 2MiB) sinkt der Overhead der Seitenumwandlung deutlich, da weniger Seitentabellenverwaltung und Referenzierungen nötig sind
  • Beim Einsatz von Huge Pages im Programm steigt der maximale Durchsatz auf 51.0GiB/s, also um weitere etwa 50 %

Einsatz von Busy Loops

  • Verbleibende Engpässe sind Synchronisierungsvorgänge wie das Warten auf freien Platz in der Pipe und das Aufwecken des Readers
  • Mit der Option SPLICE_F_NONBLOCK und wiederholten Aufrufen in einem Busy Loop bei EAGAIN wird der Scheduling-Overhead des Kernels eliminiert
  • Mit dieser Technik steigt der maximale Durchsatz auf 62.5GiB/s, also noch einmal um 25 %
  • Busy Loops verbrauchen zwar 100 % der CPU-Ressourcen, sind auf High-Performance-Servern jedoch ein verbreitetes Muster

Fazit und weitere Punkte

  • Durch die Analyse mit perf und des Linux-Quellcodes wird Schritt für Schritt gezeigt, wie sich die Pipe-Leistung drastisch steigern lässt
  • Wichtige Themen der High-Performance-Programmierung wie Pipes, splice, Paging, Zero-Copy und Synchronisierungskosten lassen sich anhand konkreter Beispiele nachvollziehen
  • Im tatsächlichen Code werden zusätzliche Performance-Tunings eingesetzt, etwa die Allokation von Puffern auf unterschiedlichen Seiten, um Refcount-Contention zu verringern
  • Die Tests werden ausgeführt, indem jeder Programmprozess mit taskset an einen eigenen Core gebunden wird
  • Die splice-Familie kann konzeptionell riskant sein und ist unter einigen Kernel-Entwicklern seit Langem umstritten

3 Kommentare

 
iolothebard 2025-06-27

Wow! Interessant! (Ich habe zwar überhaupt keine Ahnung, worum es geht …)

 
doolayer 2025-06-26

|

 
GN⁺ 2025-06-24
Hacker-News-Kommentare
  • Ich werde die Erfahrung nie vergessen, als ich eine Linux-Anwendung auf Pipe-Basis nach Windows portiert habe. Da es sich um einen POSIX-Standard handelt, dachte ich, die Performance würde sich nicht groß unterscheiden, aber es war unglaublich langsam. Das Problem ging so weit, dass beim Warten auf den Verbindungsaufbau der Pipe fast das ganze Windows-System zum Stillstand kam. Als ich einige Jahre später dasselbe unter Win10 noch einmal in C# implementierte, war es etwas besser, aber der Performance-Unterschied war immer noch ziemlich beschämend.

    • Soweit ich weiß, wurden Windows in den letzten Jahren AF_UNIX-Sockets hinzugefügt. Ich frage mich, welche Variante im Vergleich zu Win32-Pipes performanter ist. Meine Vermutung wäre, dass AF_UNIX besser ist.

    • Wenn du sagst, „die Performance war miserabel“, meinst du dann das I/O, nachdem die Pipe bereits verbunden war, oder den Prozess davor? Wenn es nach dem Verbindungsaufbau war, wäre das überraschend. Falls aber das wiederholte Verbinden/Trennen das Problem war, kann ich akzeptieren, dass das OS dafür nicht optimiert ist. In der Praxis braucht man das ja fast nie, daher hängt die Bewertung vom Anwendungsfall ab.

    • Was ich kürzlich überprüft habe: Unter Windows ist die Performance von lokalem TCP deutlich besser als die von Pipes.

    • POSIX definiert nur das Verhalten, nicht die Performance. Das erinnert daran, dass jede Plattform und jedes OS eigene Performance-Besonderheiten hat.

    • Ich hatte früher die umgekehrte Erfahrung. Zwar nicht mit Pipes, aber als eine PHP-App unter Linux mit einer SOAP-API auf .NET-Basis kommunizierte, war die Antwortzeit der .NET-Implementierung besser.

  • Zur Referenz gibt es verschiedene Ansätze wie readv() / writev(), splice(), sendfile(), funopen(), io_buffer() usw. splice() ist beim Zero-Copy-Transfer großer Datenmengen zwischen Pipes und UNIX-Sockets hervorragend, ist aber Linux-spezifisch. splice() ist die schnellste Methode für direkte Datenübertragung ohne Speicherallokation im User Space, ohne zusätzliche Pufferverwaltung, ohne memcpy() und ohne das Durchlaufen von iovecs. Für BSD-Systeme wurde außerdem um Bestätigung gebeten, ob readv()/writev() für Pipes dort tatsächlich optimal ist. So oder so wurde der Artikel als sehr beeindruckend gelobt.

    • sendfile() bietet sehr hohe Performance über Zero-Copy von Datei → Socket und ist sowohl unter Linux als auch BSD verfügbar. Es unterstützt allerdings nur Datei → Socket. sendmsg() kann für gewöhnliche Pipes nicht verwendet werden; es ist für UNIX-Domain-/INET-/andere Sockets gedacht. Nebenbei: Unter Linux habe ich sendfile dank der internen Implementierung über splice tatsächlich auch schon für Datei → Blockgerät-Transfers verwendet.

    • splice() ist unter Linux das Beste für ultraschnelle Übertragung großer Datenmengen zwischen Pipes, aber mit korrekt eingesetztem io_uring ist eine ähnliche oder sogar bessere Performance zu erwarten.

    • Shared Memory wie shm_open zusammen mit File-Descriptor-Passing ist in der Praxis schneller und vollständig portabel.

  • Es wurde darauf hingewiesen, dass es schon in früheren HN-Diskussionen lebhafte Debatten zu diesem Artikel gab: https://news.ycombinator.com/item?id=31592934 (200 Kommentare), https://news.ycombinator.com/item?id=37782493 (105 Kommentare).

  • Ein wirklich großartiger Artikel, und es ist sehr schön, dass er regelmäßig wieder zur Sprache kommt.

    • Korrektur eines Tippfehlers: comes → comes up
  • Schade, dass es noch gar keine Kommentare gibt. Ich würde splice gern öfter einsetzen, aber die am Ende des Textes erwähnten Sicherheits- oder ABI-Kompatibilitätsprobleme machen mir Sorgen. Es wurde auch die Frage gestellt, ob splice langfristig weiter gepflegt wird und wie schwierig es wäre, Standard-Pipes so zu patchen, dass sie für Performance-Gewinne immer splice verwenden.

  • Frage, ob es auf aktuellem Linux etwas gibt, das den Doors von SunOS ähnelt. Gesucht wird etwas Besseres als AF_UNIX für Embedded-Anwendungen, die sehr latenzkritische kleine Datenaustausche benötigen.

    • Shared Memory ist in Bezug auf Latenz am schnellsten, erfordert aber das Aufwecken von Tasks, meist über futex. Google arbeitete an dem System-Call FUTEX_SWAP, der eine direkte Übergabe von einem Task an einen anderen ermöglichen sollte, aber wie der Stand inzwischen ist, weiß ich nicht.

    • Weil „Doors“ ein zu allgemeines Wort ist, wurde um eine Erklärung gebeten, wonach genau man suchen soll.

    • Es wurde um mehr Informationen gebeten, was aktuell das Problem mit AF_UNIX ist: Fehlt eine benötigte Funktion, ist die Latenz höher als gewünscht oder passt die Server-/Client-Socket-API-Struktur einfach nicht?

  • Ergänzende Info, dass der Artikel im Jahr 2022 geschrieben wurde.