1 Punkte von GN⁺ 2024-08-27 | 1 Kommentare | Auf WhatsApp teilen
  • Wenn Daten mit write in eine Linux-Pipe geschrieben werden, ist das deutlich langsamer als ein einfacher Speicher-Write; in der Testumgebung blieb die Leistung bei 17GB/s für Pipe-Write gegenüber 167GB/s für Speicherpuffer-Write
  • Der Flaschenhals lässt sich nicht allein durch eine Datenkopie erklären; in pipe_write summieren sich auch die Kosten für Seitenallokation, Locking und Kernel-Kopierroutinen
  • vmsplice verbindet einen User-Space-Puffer mit der Pipe, ohne ihn in den Kernel zu kopieren, und umgeht damit teure Pfade wie __alloc_pages, _raw_spin_lock_irq und copy_user_enhanced_fast_string
  • Im Fizz-Buzz-Durchsatzbeispiel erreichte der Ansatz mit vmsplice auf einem einzelnen Kern 60.8GiB/s und auf mehreren Kernen 208.3GiB/s; ein separates vmsplice-Beispiel aus einem anderen Test kam auf 210GB/s
  • Wegen Korrekturen ist die Interpretation einer Strafe für fehlende SIMD-Nutzung kaum belastbar, und Interprozesskommunikation endet nicht allein im L1-Cache, sodass 167GB/s nicht als realistische Pipe-Leistung zu erwarten sind

Ausgangspunkt: die große Lücke durch vmsplice

  • Einige Programme verwenden den Systemaufruf vmsplice, um Daten schneller durch Pipes zu bewegen
  • Im Fizz-Buzz-Durchsatzwettbewerb auf Code Golf StackExchange teilten sich die Lösungen deutlich in zwei Gruppen
    • Lösungen ohne vmsplice lagen bei einigen GiB pro Sekunde, wobei neils Lösung 8.4GiB/s erreichte
    • Lösungen mit vmsplice kamen auf 15.5GiB/s von tkluck, 60.8GiB/s von ais523 und bis zu 208.3GiB/s auf mehreren Kernen bei david
  • Allein der Effekt, weniger zwischen Kernel-Space und User-Space zu kopieren, erklärt den Unterschied von etwa dem Siebenfachen auf einem einzelnen Kern nur schwer
  • Auch in eigenen Tests erreichte ais523s Lösung 96.4GiB/s, und davids Lösung kam mit 7 Kernen auf 277GB/s, also etwa 40GB/s pro Kern

Referenzwert: Speicher-Write im User Space

  • Ein Rust-Programm, das ohne Systemaufrufe wiederholt einen 32KiB-Puffer im User Space kopiert, erreichte in der Testumgebung 167GB/s
  • Dieser Wert wurde als passend zur Schreibgeschwindigkeit des L1-Caches der verwendeten CPU betrachtet
    • Das Testsystem bestand aus einem Ryzen 9 7950X3D, DDR5 6000T/s, Debian 12 und Linux 6.1.0-18-amd64
    • CPU-Mitigations waren mit mitigations=off deaktiviert
  • ftrace-Profiling zeigte, dass die meiste Zeit in __memset_avx512_unaligned_erms verbracht wurde
  • Eine Korrektur schränkt diese Interpretation jedoch ein
    • Die Instruktion am Unterbrechungspunkt war rep stos, also keine AVX-512-Instruktion
    • Auch wenn die Ausführung auf AVX2 und SSE2 beschränkt wurde, blieb der Durchsatz bei 167GB/s, weil in allen Fällen rep stos verwendet wurde

Die tatsächlichen Kosten beim Schreiben per write in eine Pipe

  • Wird ein Puffer derselben Größe mit stdout.write() in eine Pipe geschrieben und mit pv >/dev/null konsumiert, fällt der Durchsatz auf 17GB/s
  • Profiling zeigte, dass die meiste Zeit innerhalb des Systemaufrufs write verbracht wurde, insbesondere 95% in pipe_write
  • In pipe_write verursachen Seitenerzeugung, Locking und Kopieren gemeinsam Kosten
    • __alloc_pages: 36% der Gesamtzeit, zur Bereitstellung neuer Speicherseiten für die Pipe
    • __mutex_lock.constprop.0: 25% der Gesamtzeit, als Locking-Kosten für den Pipe-Write
    • _raw_spin_lock_irq: 5% der Gesamtzeit, ebenfalls als Locking-Kosten im Zusammenhang mit Pipe-Writes
    • copy_user_enhanced_fast_string: etwa 20% der Gesamtzeit, für das Kopieren der Daten aus dem User Space in den Kernel
  • Da pv die Seiten per splice nach /dev/null weiterreicht und dabei verbraucht, ist es schwierig, in einer Schleife immer wieder dieselben wenigen Seiten wiederzuverwenden

Kernel-Kopierroutine und korrigierte Interpretation

  • Eine Disassemblierung von copy_user_enhanced_fast_string zeigt, dass große Pufferkopien mit der Instruktion REP MOV ausgeführt werden
  • Die Funktion ist nicht in C, sondern als Assemblercode im Linux-Kernel implementiert; es handelt sich also nicht um einen fehlenden Compiler-Optimierungsschritt, sondern um einen absichtlichen Pfad
  • Im ursprünglichen Test wurden im User Space rep movsb direkt aufgerufen und damit 80GB/s erreicht, was zur Interpretation führte, die Kernel-Kopierroutine sei etwa doppelt so langsam
  • Spätere Korrekturen änderten jedoch die Versuchslage
    • Zwei 32KiB-Puffer sättigen bereits den L1-Daten-Cache
    • Mit 16KiB-Puffern stieg die Leistung auf 153GB/s
  • Daher ist die ursprüngliche Deutung, dass die fehlende Nutzung von Vektor-Instruktionen beim Kernel-Kopieren eine große Strafe verursache, kaum belastbar
  • Dennoch bleibt bestehen, dass beim Pipe-Write der Overhead der Speicherverwaltung groß ist

Welche Kernel-Pfade vmsplice vermeidet

  • vmsplice übergibt einen ganzen User-Space-Puffer an die Pipe, ohne ihn vollständig in den Kernel zu kopieren, und senkt damit die Kosten der Pipe-Nutzung
  • Das in Francescos pipes-speed-test enthaltene Beispiel ./write dient als minimales Beispiel, das unendlich viele 'X' schreibt
  • Dieses Beispiel erreichte 210GB/s, unterscheidet sich aber von typischen Daten-Workloads, weil derselbe Puffer wiederholt an vmsplice übergeben wird
    • Wenn der Bytestrom nicht konstant ist, müssen neue Daten in den Puffer geschrieben werden
    • Dann wird die einfache Obergrenze des Speicher-Writes von 167GB/s wieder relevant
  • Auch auf dem vmsplice-Pfad entfielen 37% der Zeit auf __mutex_lock.constprop.0
  • Die bei write sichtbaren Pfade __alloc_pages, _raw_spin_lock_irq und copy_user_enhanced_fast_string tauchen dort jedoch nicht auf
  • Stattdessen erscheinen add_to_pipe, import_iovec und iov_iter_get_pages2 als wichtige Pfade und zeigen, dass vmsplice die teuren Teile von write umgeht

Verbleibende Schlussfolgerungen und Hinweise

  • Im Versuch war der Pfad, der mit write in eine Linux-Pipe schreibt, etwa 10-mal langsamer als ein einfacher Speicher-Write
  • Die ursprüngliche Schlussfolgerung lautete, dass bei Pipe-Writes Locking-Kosten und Kosten für das Sichern und Wiederherstellen des SIMD-Kontexts hoch seien und dass splice und vmsplice diese umgehen
  • Nach den Korrekturen sollte die Schlussfolgerung enger gefasst werden
    • Der Overhead der Speicherverwaltung im Kernel bleibt ein wichtiger Faktor für den Leistungsabfall von Pipes
    • Die Interpretation, dass fehlende Nutzung von Vektor-Instruktionen eine so große Strafe verursacht wie angenommen, ist ungenau
    • Interprozesskommunikation kann nicht allein im L1-Cache stattfinden, daher sind 167GB/s als realer Durchsatz inklusive Pipe-Read kaum erreichbar
  • Einige erhebliche Fehler wurden korrigiert, und die Verlässlichkeit der Ergebnisse kann dadurch eingeschränkt sein; die Zahlen sollten daher eher als Hinweis auf die Größenordnung gelesen werden
  • Wer den Pipe-Durchsatz erhöhen will, sollte nicht nur die Zahl der Systemaufrufe reduzieren, sondern auch die internen Kernel-Pfade von write und die Art der Pufferverarbeitung berücksichtigen

1 Kommentare

 
GN⁺ 2024-08-27
Meinungen auf Hacker News
  • Es gibt ein Nebenprojekt, das dieses Problem angehen will: https://lwn.net/Articles/976836/
    Die Idee ist, einen Systemaufruf zu schaffen, der für alle unterstützten File Descriptors einen Ringbuffer liefert. Pipes wären ebenfalls eingeschlossen; wenn beide Enden die Nutzung eines Ringbuffers unterstützen, könnten sie denselben Ringbuffer mappen und Zero-Copy-I/O betreiben, in manchen Fällen sogar ganz ohne Kernel-Aufrufe. Es werden Mitstreiter gesucht.

    • Zumindest für User-Space-Zwecke bin ich mir nicht sicher, ob dafür unbedingt neue Kernel-Funktionalität nötig ist. Ich habe früher einmal einen Single-Producer/Single-Consumer-Ringbuffer im User Space implementiert, der das Verhalten von Pipes mit eventfd ziemlich gut nachbildet.
      Wenn der Ringbuffer voll oder leer ist, sind sleep/poll möglich; ansonsten arbeitet er ohne Locks und ohne Systemaufruf-Overhead.
    • Ich frage mich, ob ein standardisierter Signalisierungsmechanismus geplant ist, mit dem beide Seiten einer Pipe der Gegenseite mitteilen, dass sie Ringbuffer unterstützen. Dann könnte libc das transparent handhaben; andernfalls sehe ich speziell bei Pipes nicht recht, welchen Vorteil das gegenüber Shared Memory plus futex-Synchronisation hätte.
    • Vermutlich könnte auch ringbuffer_wait() in poll() als lesebereit signalisiert werden.
    • Ich frage mich, ob bestehende Ringbuffer-Interfaces das nutzen werden, oder ob es zu einer xkcd927-Situation kommt. Interessant wirkt der Versuch jedenfalls.
    • Buffering existiert aus gutem Grund, und dieser Ansatz kann in Skripten merkwürdige Fehlermodi und Schwachstellen erzeugen. Der Kernpunkt ist, dass jeder Stream-Produzent langsamer werden kann als ein bestimmter Konsument.
      Schon ein kurzer Hiccup reicht aus, damit eine Pipe ohne ausreichendes Buffering völlig aus dem Tritt gerät, und die nötige Buffergröße variiert von System zu System.
  • Dass JMP nicht einfach RET ist, liegt an der Option CONFIG_RETHUNK. Was man in der objdump-Disassemblierung sieht, ist das Ergebnis davon, dass RET durch JMP __x86_return_thunk ersetzt wurde.
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
    https://github.com/torvalds/linux/blob/v6.1/arch/x86/lib/ret...
    Die NOP-Instruktionen am Anfang und Ende der Funktion sind nicht für ftrace gedacht, sondern stammen aus den Makros ASM_CLAC/ASM_STAC. Diese Makros lassen Platz, der zur Laufzeit mit CLAC/STAC-Instruktionen gefüllt wird, wenn X86_FEATURE_SMAP erkannt wird. Beide Instruktionen sind 3 Byte groß, also entspricht das der Zahl der NOPs.
    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/...

    • Es dürfte wohl nur eine Handvoll Kernel-Entwickler geben, die solche Details kennen und sich trotzdem so einen schelmischen Wegwerf-Nickname aussuchen würden.
  • Linux-Pipes als „langsam“ zu bezeichnen, ist so, als würde man einen Toyota Corolla „langsam“ nennen. Wenn es kein extremer Anwendungsfall ist, sind sie schnell genug.
    Fährst du Autorennen? Und zwar in einer Disziplin, in der Geschwindigkeit wichtiger ist als Fahrkönnen? Dann kauf dir ein schnelleres Auto. Sonst fahr weiter Corolla.

    • Das ist nicht irgendein Projektcode, der während seiner Lebensdauer nur ein paar Milliarden Mal ausgeführt wird, sondern Code, der auf Millionen bis Milliarden Computern häufig genutzt wird.
      Deshalb lohnt es sich wirtschaftlich, viel Zeit in Optimierung zu stecken, selbst wenn die Effizienz nur minimal steigt.
    • Im konkreten Fall des Autors bewegen die angeblich langsamen Pipes tatsächlich Daten mit 17 GB/s, also mehr als 130 Gbps.
      Ich habe Pipes seit über zehn Jahren für viele Zwecke verwendet und bin nie an der Pipe-Geschwindigkeit gescheitert; meistens waren vermutlich Werkzeuge wie tar, gzip, find, grep oder nc der Flaschenhals. Wobei auch diese Tools gemessen an dem, was sie tun, ziemlich schnell sind.
    • Es gibt ein Projekt, das ein proprietäres SDK zum Dekodieren von Rohvideo nutzt. Die dekodierten Daten werden als reines RGBA ausgegeben, und FFMpeg liest sie über eine Pipe ein, um sie wieder in einen Standard-Codec zu enkodieren.
      FFMpeg kann das Non-Free-SDK nicht in den Quellcode aufnehmen, und reines RGBA in Dateien zu speichern ist absurd unpraktikabel. Daher sind Pipes die einzige Möglichkeit, und es gibt einen legitimen Grund, Pipes mit hohem Durchsatz zu brauchen.
    • Etwas, das überall verwendet wird, um ein paar Prozent schneller zu machen, ist eine durchaus lohnende Investition. Selbst wenn einzelne Aufgaben nicht viel schneller werden, spart das weltweit zusammengenommen enorm viel Strom und Zeit.
    • Manchmal ist ein schnellerer Corolla tatsächlich die beste Antwort.
      https://www.toyota.com/grcorolla/
      Diese Autos haben beeindruckendes Engineering und beeindruckende Performance und sind gewissermaßen auch ein Hack, um Regeln zu umgehen, die es schwierig machten, den ursprünglich vorgesehenen GR Yaris auf den US-Markt zu bringen. Ich denke, da steckt genug Engineering-/Performance-/Hacking-/Marktkontext drin, dass HN-Leute das mit Humor nehmen können. Außerdem fährt und beherrscht der Firmenchef das Auto noch selbst.
  • Abseits des Kerns des Artikels: Auf modernen CPUs ist rep movsb so schnell wie die schnellste vektorisierte Version. Denn die CPU weiß, dass sie dies beschleunigen kann.
    Auch der Name der Kernel-Funktion copy_user_enhanced_fast_string deutet darauf hin. Die zugehörigen CPU-Features sind ERMS (Enhanced Repeat Move String, macht rep movsb ab einer bestimmten Länge schnell) und FSRM (Fast Short Repeat Move String, macht auch kurze Kopien schnell).

    • Das ist nicht alles. rep movsb ist bis zu einem bestimmten Schwellenwert schnell, danach sind normale Stores oder nicht-temporale Stores (non-temporal stores) schneller.
      Alle Schwellenwerte sind unter https://codebrowser.dev/glibc/glibc/sysdeps/x86_64/multiarch... beschrieben.
      Und diese Werte sind nicht einmal fest; Noah Goldstein aktualisiert sie immer noch jedes Jahr.
    • Ebenfalls sehenswert: Linux hat seit dem im Artikel verwendeten Kernel 6.1 mehrfach geändert, wie ERMS und FSRM beim Kopieren auf x86 eingesetzt werden. Zur Einordnung: Auf meiner Maschine, die sowohl FSRM als auch ERMS hat — überraschenderweise impliziert Ersteres nicht Letzteres — kommt Linux 6.8 mit einer normalen Pipe und einem 32-KiB-Puffer allein auf 17 GB/s.
    • Bei kurzen memcpys warte ich immer noch darauf, dass rep movsb und rep stosb schnell genug werden, um die einfache C-Loop-Version löschen zu können.
    • Dann frage ich mich, wann C-Compiler wohl auch ein memcpy() mit variabler Länge inline setzen, so wie sie es bei memcpy mit fester Länge tun.
  • Zu AVX512 gab es im Artikel einen Punkt, den ich nicht gesehen habe: Neben dem Overhead von xsave/xrstor verbraucht AVX512 viel Strom und löst CPU-Frequenzskalierung aus. Details dazu und wie subtil das werden kann, finden sich in [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...

    • Das gilt nur für bestimmte Intel-CPU-Modelle.
  • Fast jede Form von Interprozesskommunikation ist „langsam“. Man entscheidet sich, für Sicherheit Performance-Kosten zu zahlen.

    • So hohe Kosten muss man dafür nicht zahlen. Eine Pipe bietet fast nichts, also sollte sie auch fast nichts kosten.
      Insbesondere gibt es kaum einen Grund, warum die schnellste Interprozesskommunikation langsamer sein sollte als ein langer Funktionsaufruf.
    • Pipes existieren nicht der Sicherheit wegen, sondern als Optimierung, um Daten zwischen bestehenden Programmen weiterzureichen.
  • Es gibt wieder den Hug of Death von Hacker News. Dank WordPress-Page-Caching ist es besser als beim letzten Mal, aber es kann trotzdem ein paar Sekunden dauern, bis die Seite lädt; bitte habt Nachsicht.

  • Ich habe nie wirklich verstanden, warum splice so langsam sein muss. Als Gründe dafür, dass es langsamer als vmsplice ist, wurden Pufferallokation und die Verwendung skalarer Instruktionen genannt, aber ich verstehe nicht, warum das nötig ist.
    Warum kann man splice nicht einfach wie vmsplice neu implementieren? Es gibt sicher einen guten Grund, aber ich glaube, ich übersehe ihn.

  • Eine Version mit io_uring wäre ebenfalls interessant. Man könnte Puffer vorab mit dem Kernel teilen und so einige Kopien vermeiden, und vermutlich auch den System-Call-Overhead umgehen. Letzterer scheint hier allerdings nahezu vernachlässigbar zu sein.

  • Für einen Blog, der rund 20 Sekunden zum Laden braucht, ist das eine ziemlich gewagte Behauptung.

    • Da der Artikel ganz oben auf Hacker News gelandet ist, sollte man wohl etwas nachsichtig sein.
      Der Artikel selbst wirkt hervorragend, und es gibt viel darüber zu lernen, was intern passiert.