Leistungsabfall bei Linux-Pipes
(qsantos.fr)- Wenn Daten mit
writein 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_writesummieren sich auch die Kosten für Seitenallokation, Locking und Kernel-Kopierroutinen vmspliceverbindet 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_irqundcopy_user_enhanced_fast_string- Im Fizz-Buzz-Durchsatzbeispiel erreichte der Ansatz mit
vmspliceauf einem einzelnen Kern 60.8GiB/s und auf mehreren Kernen 208.3GiB/s; ein separatesvmsplice-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
vmsplicelagen bei einigen GiB pro Sekunde, wobei neils Lösung 8.4GiB/s erreichte - Lösungen mit
vmsplicekamen auf 15.5GiB/s von tkluck, 60.8GiB/s von ais523 und bis zu 208.3GiB/s auf mehreren Kernen bei david
- Lösungen ohne
- 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=offdeaktiviert
ftrace-Profiling zeigte, dass die meiste Zeit in__memset_avx512_unaligned_ermsverbracht 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 stosverwendet wurde
- Die Instruktion am Unterbrechungspunkt war
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 mitpv >/dev/nullkonsumiert, fällt der Durchsatz auf 17GB/s - Profiling zeigte, dass die meiste Zeit innerhalb des Systemaufrufs
writeverbracht wurde, insbesondere 95% inpipe_write - In
pipe_writeverursachen 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-Writescopy_user_enhanced_fast_string: etwa 20% der Gesamtzeit, für das Kopieren der Daten aus dem User Space in den Kernel
- Da
pvdie Seiten persplicenach/dev/nullweiterreicht 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_stringzeigt, dass große Pufferkopien mit der InstruktionREP MOVausgefü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 movsbdirekt 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-testenthaltene Beispiel./writedient 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
writesichtbaren Pfade__alloc_pages,_raw_spin_lock_irqundcopy_user_enhanced_fast_stringtauchen dort jedoch nicht auf - Stattdessen erscheinen
add_to_pipe,import_iovecundiov_iter_get_pages2als wichtige Pfade und zeigen, dassvmsplicedie teuren Teile vonwriteumgeht
Verbleibende Schlussfolgerungen und Hinweise
- Im Versuch war der Pfad, der mit
writein 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
spliceundvmsplicediese 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
writeund die Art der Pufferverarbeitung berücksichtigen
1 Kommentare
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.
Wenn der Ringbuffer voll oder leer ist, sind sleep/poll möglich; ansonsten arbeitet er ohne Locks und ohne Systemaufruf-Overhead.
ringbuffer_wait()inpoll()als lesebereit signalisiert werden.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
JMPnicht einfachRETist, liegt an der Option CONFIG_RETHUNK. Was man in derobjdump-Disassemblierung sieht, ist das Ergebnis davon, dassRETdurchJMP __x86_return_thunkersetzt 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/...
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.
Deshalb lohnt es sich wirtschaftlich, viel Zeit in Optimierung zu stecken, selbst wenn die Effizienz nur minimal steigt.
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.
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.
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 movsbso 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_stringdeutet darauf hin. Die zugehörigen CPU-Features sind ERMS (Enhanced Repeat Move String, machtrep movsbab einer bestimmten Länge schnell) und FSRM (Fast Short Repeat Move String, macht auch kurze Kopien schnell).rep movsbist 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.
memcpys warte ich immer noch darauf, dassrep movsbundrep stosbschnell genug werden, um die einfache C-Loop-Version löschen zu können.memcpy()mit variabler Länge inline setzen, so wie sie es beimemcpymit fester Länge tun.Zu AVX512 gab es im Artikel einen Punkt, den ich nicht gesehen habe: Neben dem Overhead von
xsave/xrstorverbraucht 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...
Fast jede Form von Interprozesskommunikation ist „langsam“. Man entscheidet sich, für Sicherheit Performance-Kosten zu zahlen.
Insbesondere gibt es kaum einen Grund, warum die schnellste Interprozesskommunikation langsamer sein sollte als ein langer Funktionsaufruf.
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
spliceso langsam sein muss. Als Gründe dafür, dass es langsamer alsvmspliceist, wurden Pufferallokation und die Verwendung skalarer Instruktionen genannt, aber ich verstehe nicht, warum das nötig ist.Warum kann man
splicenicht einfach wievmspliceneu implementieren? Es gibt sicher einen guten Grund, aber ich glaube, ich übersehe ihn.vmsplicefunktioniert nicht mit allen Arten von Dateideskriptoren.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.
Der Artikel selbst wirkt hervorragend, und es gibt viel darüber zu lernen, was intern passiert.